diff --git a/.github/workflows/ci-humble.yml b/.github/workflows/ci-humble.yml new file mode 100644 index 0000000..64128fb --- /dev/null +++ b/.github/workflows/ci-humble.yml @@ -0,0 +1,47 @@ +name: Humble + +on: + push: + branches: [main, chore/*, feat/*, fix/*] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-22.04 + container: ros:humble-ros-base + env: + PIP_BREAK_SYSTEM_PACKAGES: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + path: src/composer + + - name: Clone muto_msgs + run: | + mkdir -p /tmp/ws/src + ln -s $GITHUB_WORKSPACE/src/composer /tmp/ws/src/composer + git clone https://github.com/eclipse-muto/messages.git /tmp/ws/src/messages + + - name: Install dependencies + run: | + apt-get update -y + apt-get install -y python3-pip python3-colcon-common-extensions python3-pytest git + cd /tmp/ws + . /opt/ros/humble/setup.sh + rosdep update + rosdep install --from-paths src --ignore-src -r -y --rosdistro humble + + - name: Build + run: | + cd /tmp/ws + . /opt/ros/humble/setup.sh + colcon build --packages-select muto_msgs muto_composer + + - name: Test + run: | + cd /tmp/ws + . /opt/ros/humble/setup.sh + . install/setup.sh + python3 -m pytest src/composer/test/ -v diff --git a/.github/workflows/ci-jazzy.yml b/.github/workflows/ci-jazzy.yml new file mode 100644 index 0000000..e7db577 --- /dev/null +++ b/.github/workflows/ci-jazzy.yml @@ -0,0 +1,47 @@ +name: Jazzy + +on: + push: + branches: [main, chore/*, feat/*, fix/*] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-24.04 + container: ros:jazzy-ros-base + env: + PIP_BREAK_SYSTEM_PACKAGES: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + path: src/composer + + - name: Clone muto_msgs + run: | + mkdir -p /tmp/ws/src + ln -s $GITHUB_WORKSPACE/src/composer /tmp/ws/src/composer + git clone https://github.com/eclipse-muto/messages.git /tmp/ws/src/messages + + - name: Install dependencies + run: | + apt-get update -y + apt-get install -y python3-pip python3-colcon-common-extensions python3-pytest git + cd /tmp/ws + . /opt/ros/jazzy/setup.sh + rosdep update + rosdep install --from-paths src --ignore-src -r -y --rosdistro jazzy + + - name: Build + run: | + cd /tmp/ws + . /opt/ros/jazzy/setup.sh + colcon build --packages-select muto_msgs muto_composer + + - name: Test + run: | + cd /tmp/ws + . /opt/ros/jazzy/setup.sh + . install/setup.sh + python3 -m pytest src/composer/test/ -v diff --git a/.github/workflows/colcon-build.yml b/.github/workflows/colcon-build.yml deleted file mode 100644 index abf09bf..0000000 --- a/.github/workflows/colcon-build.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Composer CI - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - container: osrf/ros:humble-desktop - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - path: 'src/composer' - - - name: Setup workspace - run: | - mkdir -p /tmp/ws/src - ln -s $(pwd)/src/composer /tmp/ws/src/composer - cd /tmp/ws - . /opt/ros/humble/setup.sh - shell: bash - - - name: Clone messages - run: | - git clone https://github.com/eclipse-muto/messages.git /tmp/ws/src/messages - shell: bash - - - name: Install dependencies - run: | - cd /tmp/ws - apt-get update -y - apt-get install -y python3-pip \ - python3-flake8 \ - python3-autopep8 \ - bc - pip3 install setuptools==58.2.0 - pip3 install requests coverage - rosdep update - rosdep install --from-paths src --ignore-src -r -y --rosdistro humble - shell: bash - - - name: Build - shell: bash - run: | - cd /tmp/ws - source /opt/ros/humble/setup.bash - echo "CMAKE_PREFIX_PATH=$CMAKE_PREFIX_PATH" - colcon build --symlink-install --packages-select muto_msgs composer - - - name: Test - shell: bash - run: | - cd /tmp/ws - source /opt/ros/humble/setup.sh - source install/setup.sh - colcon test --event-handlers console_direct+ --packages-select muto_msgs composer - colcon test-result --verbose - - - name: Coverage Check (must be >= 80%) - shell: bash - run: | - cd /tmp/ws - source /opt/ros/humble/setup.sh - source install/setup.sh - cd /tmp/ws/src/composer - coverage run -m --source=/tmp/ws/src/composer --omit=/tmp/ws/src/composer/test/*,/tmp/ws/src/composer/setup.py unittest discover -s /tmp/ws/src/composer/test - coverage report -m - COVERAGE=$(coverage report | grep TOTAL | awk '{print $NF}' | sed 's/%//') - echo "Total coverage: $COVERAGE%" - if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then - echo "❌ Coverage is below 80%" - else - echo "✅ Coverage is above threshold" - fi - - - name: Lint - run: | - cd /tmp/ws/src/composer - python3 -m flake8 --max-line-length 100 composer test || true - shell: bash diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5276e66 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Lint + +on: + push: + branches: [main, chore/*, feat/*, fix/*] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - run: pip install ruff mypy + - name: Ruff check + run: ruff check . + - name: Ruff format check + run: ruff format --check . + - name: Mypy + run: mypy muto_composer/ + continue-on-error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d90152 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-xml + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.0.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..bbd355c --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,21 @@ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Changelog for package muto_composer +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Forthcoming +----------- +* Initial release preparation for packages.ros.org +* Renamed package from ``composer`` to ``muto_composer`` to avoid name collisions +* Added pipeline-based stack deployment and orchestration engine +* Added pluggable stack handlers for archive, JSON, and Ditto content types +* Added provision, compose, and launch plugin architecture +* Added process monitoring with crash detection and event handling +* Added rollback support with persistent state management +* Added launch file introspection and composable node support +* Added managed (lifecycle) node support +* Added watchdog for deployment health monitoring +* Standardized copyright headers to SPDX EPL-2.0 format +* Added ruff and mypy linting configuration +* Added pre-commit hooks for code quality enforcement +* Set python_requires>=3.10 for Humble/Jazzy compatibility +* Contributors: Alp Sarıca, Deniz Memis, Ibrahim Sel, Naci Dai, Nazli Eker, Samet Karabulut diff --git a/README.md b/README.md index 8193ba7..8376f39 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ -# Composer +# Muto Composer -**Composer** is a ROS 2 package designed to organize and automate the software deployment process to a fleet of vehicles. It streamlines the workflow by managing stack definitions, resolving dependencies, handling buildjobs and orchestrating the execution of various pipelines. +| ROS 2 Distro | Ubuntu | Python | Status | +|---|---|---|---| +| Humble | 22.04 | 3.10 | [![Humble](https://github.com/ibrahimsel/composer/actions/workflows/ci-humble.yml/badge.svg)](https://github.com/ibrahimsel/composer/actions/workflows/ci-humble.yml) | +| Jazzy | 24.04 | 3.12 | [![Jazzy](https://github.com/ibrahimsel/composer/actions/workflows/ci-jazzy.yml/badge.svg)](https://github.com/ibrahimsel/composer/actions/workflows/ci-jazzy.yml) | + +**Muto Composer** is a ROS 2 package designed to organize and automate the software deployment process to a fleet of vehicles. It streamlines the workflow by managing stack definitions, resolving dependencies, handling buildjobs and orchestrating the execution of various pipelines. ## Table of Contents @@ -58,8 +63,8 @@ For a detailed overview, refer to the [Architecture Documentation](docs/architec ### Prerequisites -- **ROS 2 Foxy** or later installed on your system. -- **Python 3.8** or later. +- **ROS 2 Humble** or later installed on your system. +- **Python 3.10** or later. - Ensure that you have `colcon` and `rosdep` installed for building and dependency management. ```bash diff --git a/config/composer.yaml b/config/muto_composer.yaml similarity index 100% rename from config/composer.yaml rename to config/muto_composer.yaml diff --git a/composer/__init__.py b/muto_composer/__init__.py similarity index 100% rename from composer/__init__.py rename to muto_composer/__init__.py diff --git a/composer/events.py b/muto_composer/events.py similarity index 64% rename from composer/events.py rename to muto_composer/events.py index 5f0c7c3..ccc9667 100644 --- a/composer/events.py +++ b/muto_composer/events.py @@ -16,18 +16,18 @@ Provides event-driven communication between subsystems. """ -import uuid import asyncio +import uuid +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from enum import Enum -from dataclasses import dataclass, field -from typing import Dict, Any, Optional, List, Callable -from concurrent.futures import ThreadPoolExecutor +from typing import Any class EventType(Enum): """Enumeration of all event types in the composer system.""" - + # Stack Events STACK_REQUEST = "stack.request" STACK_ANALYZED = "stack.analyzed" @@ -35,7 +35,7 @@ class EventType(Enum): STACK_MERGED = "stack.merged" STACK_VALIDATED = "stack.validated" STACK_TRANSFORMED = "stack.transformed" - + # Orchestration Events ORCHESTRATION_STARTED = "orchestration.started" ORCHESTRATION_COMPLETED = "orchestration.completed" @@ -45,7 +45,7 @@ class EventType(Enum): ROLLBACK_STARTED = "orchestration.rollback.started" ROLLBACK_COMPLETED = "orchestration.rollback.completed" ROLLBACK_FAILED = "orchestration.rollback.failed" - + # Pipeline Events PIPELINE_REQUESTED = "pipeline.requested" PIPELINE_START = "pipeline.start" @@ -58,7 +58,7 @@ class EventType(Enum): PIPELINE_ERROR = "pipeline.error" PIPELINE_FAILED = "pipeline.failed" PIPELINE_COMPENSATION_STARTED = "pipeline.compensation.started" - + # Plugin Operation Events COMPOSE_REQUESTED = "compose.requested" COMPOSE_COMPLETED = "compose.completed" @@ -66,7 +66,7 @@ class EventType(Enum): PROVISION_COMPLETED = "provision.completed" LAUNCH_REQUESTED = "launch.requested" LAUNCH_COMPLETED = "launch.completed" - + # System Events TWIN_UPDATE = "twin.update" TWIN_SYNC_REQUESTED = "twin.sync.requested" @@ -79,24 +79,30 @@ class EventType(Enum): class BaseComposeEvent: """Base class for all composer events.""" - - def __init__(self, event_type: EventType, source_component: str, - event_id: Optional[str] = None, timestamp: Optional[datetime] = None, - correlation_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, - # Common attributes used across multiple event types - stack_payload: Optional[Dict[str, Any]] = None, - stack_name: Optional[str] = None, - action: Optional[str] = None, - pipeline_name: Optional[str] = None, - execution_context: Optional[Dict[str, Any]] = None, - orchestration_id: Optional[str] = None): + + def __init__( + self, + event_type: EventType, + source_component: str, + event_id: str | None = None, + timestamp: datetime | None = None, + correlation_id: str | None = None, + metadata: dict[str, Any] | None = None, + # Common attributes used across multiple event types + stack_payload: dict[str, Any] | None = None, + stack_name: str | None = None, + action: str | None = None, + pipeline_name: str | None = None, + execution_context: dict[str, Any] | None = None, + orchestration_id: str | None = None, + ): self.event_type = event_type self.source_component = source_component self.event_id = event_id or str(uuid.uuid4()) self.timestamp = timestamp or datetime.now() self.correlation_id = correlation_id self.metadata = metadata or {} - + # Common attributes self.stack_payload = stack_payload or {} self.stack_name = stack_name @@ -108,33 +114,47 @@ def __init__(self, event_type: EventType, source_component: str, class StackRequestEvent(BaseComposeEvent): """Event triggered when a stack operation is requested.""" - - def __init__(self, event_type: EventType, source_component: str, stack_name: str, action: str, - stack_payload: Optional[Dict[str, Any]] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + stack_name: str, + action: str, + stack_payload: dict[str, Any] | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, stack_name=stack_name, action=action, stack_payload=stack_payload, - **kwargs + **kwargs, ) class StackAnalyzedEvent(BaseComposeEvent): """Event triggered when stack analysis is complete.""" - - def __init__(self, event_type: EventType, source_component: str, stack_name: str, action: str, - analysis_result: Optional[Dict[str, Any]] = None, - processing_requirements: Optional[Dict[str, Any]] = None, - stack_payload: Optional[Dict[str, Any]] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + stack_name: str, + action: str, + analysis_result: dict[str, Any] | None = None, + processing_requirements: dict[str, Any] | None = None, + stack_payload: dict[str, Any] | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, stack_name=stack_name, action=action, stack_payload=stack_payload, - **kwargs + **kwargs, ) self.analysis_result = analysis_result or {} self.processing_requirements = processing_requirements or {} @@ -144,18 +164,23 @@ def __init__(self, event_type: EventType, source_component: str, stack_name: str class StackMergedEvent(BaseComposeEvent): """Event triggered when stacks are merged.""" - - def __init__(self, event_type: EventType, source_component: str, - current_stack: Optional[Dict[str, Any]] = None, - next_stack: Optional[Dict[str, Any]] = None, - stack_payload: Optional[Dict[str, Any]] = None, - merge_strategy: str = "intelligent_merge", - conflicts_resolved: Optional[Dict[str, Any]] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + current_stack: dict[str, Any] | None = None, + next_stack: dict[str, Any] | None = None, + stack_payload: dict[str, Any] | None = None, + merge_strategy: str = "intelligent_merge", + conflicts_resolved: dict[str, Any] | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, stack_payload=stack_payload, - **kwargs + **kwargs, ) self.current_stack = current_stack or {} self.next_stack = next_stack or {} @@ -167,17 +192,22 @@ def __init__(self, event_type: EventType, source_component: str, class StackTransformedEvent(BaseComposeEvent): """Event triggered when stack transformation is complete.""" - - def __init__(self, event_type: EventType, source_component: str, - original_stack: Optional[Dict[str, Any]] = None, - stack_payload: Optional[Dict[str, Any]] = None, - expressions_resolved: Optional[Dict[str, str]] = None, - transformation_type: str = "expression_resolution", **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + original_stack: dict[str, Any] | None = None, + stack_payload: dict[str, Any] | None = None, + expressions_resolved: dict[str, str] | None = None, + transformation_type: str = "expression_resolution", + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, stack_payload=stack_payload, - **kwargs + **kwargs, ) self.original_stack = original_stack or {} # Keep transformed_stack for backwards compatibility, but map to stack_payload @@ -188,19 +218,25 @@ def __init__(self, event_type: EventType, source_component: str, class OrchestrationStartedEvent(BaseComposeEvent): """Event triggered when orchestration process begins.""" - - def __init__(self, event_type: EventType, source_component: str, action: str, - execution_plan: Optional[Dict[str, Any]] = None, - context_variables: Optional[Dict[str, Any]] = None, - stack_payload: Optional[Dict[str, Any]] = None, - orchestration_id: Optional[str] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + action: str, + execution_plan: dict[str, Any] | None = None, + context_variables: dict[str, Any] | None = None, + stack_payload: dict[str, Any] | None = None, + orchestration_id: str | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, action=action, stack_payload=stack_payload, orchestration_id=orchestration_id or str(uuid.uuid4()), - **kwargs + **kwargs, ) self.execution_plan = execution_plan or {} self.context_variables = context_variables or {} @@ -209,15 +245,21 @@ def __init__(self, event_type: EventType, source_component: str, action: str, class OrchestrationCompletedEvent(BaseComposeEvent): """Event triggered when orchestration completes successfully.""" - def __init__(self, event_type: EventType, source_component: str, orchestration_id: str, - final_stack_state: Optional[Dict[str, Any]] = None, - execution_summary: Optional[Dict[str, Any]] = None, - duration: float = 0.0, **kwargs): + def __init__( + self, + event_type: EventType, + source_component: str, + orchestration_id: str, + final_stack_state: dict[str, Any] | None = None, + execution_summary: dict[str, Any] | None = None, + duration: float = 0.0, + **kwargs, + ): super().__init__( event_type=event_type, source_component=source_component, orchestration_id=orchestration_id, - **kwargs + **kwargs, ) self.final_stack_state = final_stack_state or {} self.execution_summary = execution_summary or {} @@ -227,17 +269,23 @@ def __init__(self, event_type: EventType, source_component: str, orchestration_i class OrchestrationFailedEvent(BaseComposeEvent): """Event triggered when orchestration fails.""" - def __init__(self, event_type: EventType, source_component: str, orchestration_id: str, - error_details: Optional[str] = None, - failed_step: Optional[str] = None, - stack_payload: Optional[Dict[str, Any]] = None, - can_rollback: bool = False, **kwargs): + def __init__( + self, + event_type: EventType, + source_component: str, + orchestration_id: str, + error_details: str | None = None, + failed_step: str | None = None, + stack_payload: dict[str, Any] | None = None, + can_rollback: bool = False, + **kwargs, + ): super().__init__( event_type=event_type, source_component=source_component, orchestration_id=orchestration_id, stack_payload=stack_payload, - **kwargs + **kwargs, ) self.error_details = error_details or "" self.failed_step = failed_step or "" @@ -247,16 +295,21 @@ def __init__(self, event_type: EventType, source_component: str, orchestration_i class RollbackStartedEvent(BaseComposeEvent): """Event triggered when rollback to previous stack version begins.""" - def __init__(self, event_type: EventType, source_component: str, - orchestration_id: Optional[str] = None, - previous_stack: Optional[Dict[str, Any]] = None, - failed_stack: Optional[Dict[str, Any]] = None, - failure_reason: str = "", **kwargs): + def __init__( + self, + event_type: EventType, + source_component: str, + orchestration_id: str | None = None, + previous_stack: dict[str, Any] | None = None, + failed_stack: dict[str, Any] | None = None, + failure_reason: str = "", + **kwargs, + ): super().__init__( event_type=event_type, source_component=source_component, orchestration_id=orchestration_id or str(uuid.uuid4()), - **kwargs + **kwargs, ) self.previous_stack = previous_stack or {} self.failed_stack = failed_stack or {} @@ -266,15 +319,20 @@ def __init__(self, event_type: EventType, source_component: str, class RollbackCompletedEvent(BaseComposeEvent): """Event triggered when rollback completes successfully.""" - def __init__(self, event_type: EventType, source_component: str, - orchestration_id: str, - restored_stack: Optional[Dict[str, Any]] = None, - rollback_duration: float = 0.0, **kwargs): + def __init__( + self, + event_type: EventType, + source_component: str, + orchestration_id: str, + restored_stack: dict[str, Any] | None = None, + rollback_duration: float = 0.0, + **kwargs, + ): super().__init__( event_type=event_type, source_component=source_component, orchestration_id=orchestration_id, - **kwargs + **kwargs, ) self.restored_stack = restored_stack or {} self.rollback_duration = rollback_duration @@ -283,15 +341,20 @@ def __init__(self, event_type: EventType, source_component: str, class RollbackFailedEvent(BaseComposeEvent): """Event triggered when rollback fails.""" - def __init__(self, event_type: EventType, source_component: str, - orchestration_id: str, - error_details: str = "", - original_failure: str = "", **kwargs): + def __init__( + self, + event_type: EventType, + source_component: str, + orchestration_id: str, + error_details: str = "", + original_failure: str = "", + **kwargs, + ): super().__init__( event_type=event_type, source_component=source_component, orchestration_id=orchestration_id, - **kwargs + **kwargs, ) self.error_details = error_details self.original_failure = original_failure @@ -299,17 +362,23 @@ def __init__(self, event_type: EventType, source_component: str, class PipelineRequestedEvent(BaseComposeEvent): """Event triggered when a pipeline execution is requested.""" - - def __init__(self, event_type: EventType, source_component: str, pipeline_name: str, - execution_context: Optional[Dict[str, Any]] = None, - stack_payload: Optional[Dict[str, Any]] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + pipeline_name: str, + execution_context: dict[str, Any] | None = None, + stack_payload: dict[str, Any] | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, pipeline_name=pipeline_name, execution_context=execution_context, stack_payload=stack_payload, - **kwargs + **kwargs, ) # Keep stack_manifest for backwards compatibility, but map to stack_payload self.stack_manifest = self.stack_payload @@ -317,14 +386,21 @@ def __init__(self, event_type: EventType, source_component: str, pipeline_name: class PipelineStartedEvent(BaseComposeEvent): """Event triggered when a pipeline starts execution.""" - - def __init__(self, event_type: EventType, source_component: str, pipeline_name: str, execution_id: str, - steps_planned: Optional[List[str]] = None, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + pipeline_name: str, + execution_id: str, + steps_planned: list[str] | None = None, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, pipeline_name=pipeline_name, - **kwargs + **kwargs, ) self.execution_id = execution_id self.steps_planned = steps_planned or [] @@ -332,16 +408,23 @@ def __init__(self, event_type: EventType, source_component: str, pipeline_name: class PipelineCompletedEvent(BaseComposeEvent): """Event triggered when a pipeline completes successfully.""" - - def __init__(self, event_type: EventType, source_component: str, pipeline_name: str, execution_id: str, - final_result: Optional[Dict[str, Any]] = None, - steps_executed: Optional[List[str]] = None, - total_duration: float = 0.0, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + pipeline_name: str, + execution_id: str, + final_result: dict[str, Any] | None = None, + steps_executed: list[str] | None = None, + total_duration: float = 0.0, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, pipeline_name=pipeline_name, - **kwargs + **kwargs, ) self.execution_id = execution_id self.final_result = final_result or {} @@ -351,15 +434,23 @@ def __init__(self, event_type: EventType, source_component: str, pipeline_name: class PipelineFailedEvent(BaseComposeEvent): """Event triggered when a pipeline fails.""" - - def __init__(self, event_type: EventType, source_component: str, pipeline_name: str, execution_id: str, - failure_step: str, error_details: Optional[Dict[str, Any]] = None, - compensation_executed: bool = False, **kwargs): + + def __init__( + self, + event_type: EventType, + source_component: str, + pipeline_name: str, + execution_id: str, + failure_step: str, + error_details: dict[str, Any] | None = None, + compensation_executed: bool = False, + **kwargs, + ): super().__init__( - event_type=event_type, - source_component=source_component, + event_type=event_type, + source_component=source_component, pipeline_name=pipeline_name, - **kwargs + **kwargs, ) self.execution_id = execution_id self.failure_step = failure_step @@ -369,19 +460,26 @@ def __init__(self, event_type: EventType, source_component: str, pipeline_name: class StackProcessedEvent(BaseComposeEvent): """Event triggered when stack processing is complete.""" - - def __init__(self, event_type: EventType = None, source_component: str = "stack_processor", - stack_name: str = "", action: str = "", stack_payload: Optional[Dict[str, Any]] = None, - execution_requirements: Optional[Dict[str, Any]] = None, - original_payload: Optional[Dict[str, Any]] = None, - processing_applied: Optional[list] = None, **kwargs): + + def __init__( + self, + event_type: EventType = None, + source_component: str = "stack_processor", + stack_name: str = "", + action: str = "", + stack_payload: dict[str, Any] | None = None, + execution_requirements: dict[str, Any] | None = None, + original_payload: dict[str, Any] | None = None, + processing_applied: list | None = None, + **kwargs, + ): super().__init__( - event_type=event_type or EventType.STACK_PROCESSED, - source_component=source_component, + event_type=event_type or EventType.STACK_PROCESSED, + source_component=source_component, stack_name=stack_name, action=action, stack_payload=stack_payload, - **kwargs + **kwargs, ) # Keep merged_stack for backwards compatibility, but map to stack_payload self.merged_stack = self.stack_payload @@ -393,13 +491,19 @@ def __init__(self, event_type: EventType = None, source_component: str = "stack_ class TwinUpdateEvent(BaseComposeEvent): """Event triggered when a digital twin update is requested.""" - def __init__(self, event_type: EventType = None, source_component: str = "twin_integration", - twin_id: str = "", update_type: str = "", - data: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + event_type: EventType = None, + source_component: str = "twin_integration", + twin_id: str = "", + update_type: str = "", + data: dict[str, Any] | None = None, + **kwargs, + ): super().__init__( event_type=event_type or EventType.TWIN_UPDATE, source_component=source_component, - **kwargs + **kwargs, ) self.twin_id = twin_id self.update_type = update_type @@ -409,15 +513,22 @@ def __init__(self, event_type: EventType = None, source_component: str = "twin_i class ProcessCrashedEvent(BaseComposeEvent): """Event triggered when a launched process crashes unexpectedly.""" - def __init__(self, event_type: EventType = None, source_component: str = "launch_plugin", - process_name: str = "", exit_code: int = -1, - stack_name: str = "", error_message: str = "", - process_output: str = "", **kwargs): + def __init__( + self, + event_type: EventType = None, + source_component: str = "launch_plugin", + process_name: str = "", + exit_code: int = -1, + stack_name: str = "", + error_message: str = "", + process_output: str = "", + **kwargs, + ): super().__init__( event_type=event_type or EventType.PROCESS_CRASHED, source_component=source_component, stack_name=stack_name, - **kwargs + **kwargs, ) self.process_name = process_name self.exit_code = exit_code @@ -427,33 +538,31 @@ def __init__(self, event_type: EventType = None, source_component: str = "launch class PipelineEvents: """Factory class for creating pipeline-related events.""" - + @staticmethod - def create_start_event(pipeline_name: str, context: Optional[Dict[str, Any]] = None): + def create_start_event(pipeline_name: str, context: dict[str, Any] | None = None): """Create a pipeline start event.""" return PipelineStartedEvent( event_type=EventType.PIPELINE_START, source_component="pipeline_engine", pipeline_name=pipeline_name, execution_id=str(uuid.uuid4()), - metadata=context or {} + metadata=context or {}, ) - + @staticmethod - def create_completion_event(pipeline_name: str, success: bool = True, - result: Optional[Dict[str, Any]] = None): + def create_completion_event(pipeline_name: str, success: bool = True, result: dict[str, Any] | None = None): """Create a pipeline completion event.""" return PipelineCompletedEvent( event_type=EventType.PIPELINE_COMPLETE, source_component="pipeline_engine", pipeline_name=pipeline_name, execution_id=str(uuid.uuid4()), - final_result=result or {"success": success} + final_result=result or {"success": success}, ) - + @staticmethod - def create_error_event(pipeline_name: str, error: str, - context: Optional[Dict[str, Any]] = None): + def create_error_event(pipeline_name: str, error: str, context: dict[str, Any] | None = None): """Create a pipeline error event.""" return PipelineFailedEvent( event_type=EventType.PIPELINE_ERROR, @@ -461,76 +570,73 @@ def create_error_event(pipeline_name: str, error: str, pipeline_name=pipeline_name, execution_id=str(uuid.uuid4()), failure_step="unknown", - error_details={"error": error, "context": context or {}} + error_details={"error": error, "context": context or {}}, ) class EventBus: """Central event bus for composer event handling.""" - + def __init__(self, max_workers: int = 4): - self._handlers: Dict[EventType, List[Callable]] = {} - self._middleware: List[Callable] = [] + self._handlers: dict[EventType, list[Callable]] = {} + self._middleware: list[Callable] = [] self._executor = ThreadPoolExecutor(max_workers=max_workers) self._logger = None - + def set_logger(self, logger): """Set logger for event bus operations.""" self._logger = logger - + def subscribe(self, event_type: EventType, handler: Callable): """Subscribe a handler to an event type.""" if event_type not in self._handlers: self._handlers[event_type] = [] self._handlers[event_type].append(handler) - + if self._logger: self._logger.debug(f"Subscribed {handler.__name__} to {event_type.value}") - + def unsubscribe(self, event_type: EventType, handler: Callable): """Unsubscribe a handler from an event type.""" if event_type in self._handlers: self._handlers[event_type].remove(handler) - + if self._logger: self._logger.debug(f"Unsubscribed {handler.__name__} from {event_type.value}") - + def add_middleware(self, middleware: Callable): """Add middleware for event processing.""" self._middleware.append(middleware) - + async def publish(self, event: BaseComposeEvent): """Publish an event to all subscribers asynchronously.""" try: # Apply middleware for middleware in self._middleware: event = await middleware(event) - + # Get handlers for this event type handlers = self._handlers.get(event.event_type, []) - + # Execute handlers concurrently if handlers: tasks = [ - asyncio.get_event_loop().run_in_executor( - self._executor, handler, event - ) - for handler in handlers + asyncio.get_event_loop().run_in_executor(self._executor, handler, event) for handler in handlers ] await asyncio.gather(*tasks, return_exceptions=True) - + except Exception as e: if self._logger: self._logger.error(f"Error publishing event {event.event_type.value}: {e}") - + def publish_sync(self, event: BaseComposeEvent): """Synchronous event publishing for ROS callbacks.""" try: handlers = self._handlers.get(event.event_type, []) - + if self._logger: self._logger.debug(f"Publishing {event.event_type.value} to {len(handlers)} handlers") - + for handler in handlers: try: handler(event) @@ -538,7 +644,7 @@ def publish_sync(self, event: BaseComposeEvent): if self._logger: self._logger.error(f"Error in event handler {handler.__name__}: {e}") # Continue with other handlers - + except Exception as e: if self._logger: - self._logger.error(f"Error in synchronous event publishing: {e}") \ No newline at end of file + self._logger.error(f"Error in synchronous event publishing: {e}") diff --git a/composer/introspection/__init__.py b/muto_composer/introspection/__init__.py similarity index 100% rename from composer/introspection/__init__.py rename to muto_composer/introspection/__init__.py diff --git a/composer/introspection/interfaces/__init__.py b/muto_composer/introspection/interfaces/__init__.py similarity index 100% rename from composer/introspection/interfaces/__init__.py rename to muto_composer/introspection/interfaces/__init__.py diff --git a/composer/introspection/introspector.py b/muto_composer/introspection/introspector.py similarity index 64% rename from composer/introspection/introspector.py rename to muto_composer/introspection/introspector.py index 33796cd..e92dbc2 100644 --- a/composer/introspection/introspector.py +++ b/muto_composer/introspection/introspector.py @@ -13,10 +13,12 @@ import subprocess -class Introspector(): + +class Introspector: """ A ROS 2 node for introspecting and managing processes. """ + def __init__(self): """Initialize the introspector.""" @@ -28,14 +30,14 @@ def kill(self, name, pid): name: The name of the process to kill. pid: The process ID of the process to kill. """ - print(f'Attempting to kill {name} with PID: {pid}') + print(f"Attempting to kill {name} with PID: {pid}") try: - result = subprocess.run(['kill', str(pid)], check=True, capture_output=True, text=True) + result = subprocess.run(["kill", str(pid)], check=True, capture_output=True, text=True) if result.returncode == 0: - print(f'Successfully killed {name} with PID: {pid}') + print(f"Successfully killed {name} with PID: {pid}") else: - print(f'Failed to kill {name} with PID: {pid}. Return code: {result.returncode}') + print(f"Failed to kill {name} with PID: {pid}. Return code: {result.returncode}") except subprocess.CalledProcessError as e: - print(f'Kill was not successful for {name}. Error: {e.stderr}') + print(f"Kill was not successful for {name}. Error: {e.stderr}") except Exception as e: - print(f'Unexpected error while trying to kill {name}. Exception message: {e}') \ No newline at end of file + print(f"Unexpected error while trying to kill {name}. Exception message: {e}") diff --git a/composer/introspection/model/__init__.py b/muto_composer/introspection/model/__init__.py similarity index 100% rename from composer/introspection/model/__init__.py rename to muto_composer/introspection/model/__init__.py diff --git a/composer/introspection/model/difference.py b/muto_composer/introspection/model/difference.py similarity index 75% rename from composer/introspection/model/difference.py rename to muto_composer/introspection/model/difference.py index e46342c..fbe723b 100644 --- a/composer/introspection/model/difference.py +++ b/muto_composer/introspection/model/difference.py @@ -12,11 +12,10 @@ # from dataclasses import dataclass -from typing import Optional @dataclass() class Difference: - added_nodes: Optional[dict] - removed_nodes: Optional[dict] - common_nodes: Optional[dict] + added_nodes: dict | None + removed_nodes: dict | None + common_nodes: dict | None diff --git a/composer/introspection/traverser.py b/muto_composer/introspection/traverser.py similarity index 67% rename from composer/introspection/traverser.py rename to muto_composer/introspection/traverser.py index 6e1b3e2..c444d3c 100644 --- a/composer/introspection/traverser.py +++ b/muto_composer/introspection/traverser.py @@ -12,14 +12,15 @@ # -import sys import os +import sys + from launch import LaunchContext -from launch.actions import IncludeLaunchDescription, GroupAction -from launch_ros.actions import Node, ComposableNodeContainer -from launch_ros.descriptions import ComposableNode +from launch.actions import GroupAction, IncludeLaunchDescription from launch.launch_description_sources import AnyLaunchDescriptionSource from launch.utilities import perform_substitutions +from launch_ros.actions import ComposableNodeContainer, Node +from launch_ros.descriptions import ComposableNode BLUE = "\033[94m" GREEN = "\033[92m" @@ -30,50 +31,38 @@ def resolve_substitutions(context, value): # If the value is a list or tuple of substitutions, we attempt to perform them - if isinstance(value, list) or isinstance(value, tuple): + if isinstance(value, (list, tuple)): try: return "".join(perform_substitutions(context, value)) - except: + except Exception: return str(value) # If it's not a list/tuple, just convert to string return str(value) -def recursively_extract_entities( - entities, context, nodes, composable_nodes, containers -): +def recursively_extract_entities(entities, context, nodes, composable_nodes, containers): for entity in entities: try: if isinstance(entity, IncludeLaunchDescription): - included_ld = entity.launch_description_source.get_launch_description( - context=context - ) + included_ld = entity.launch_description_source.get_launch_description(context=context) included_ld.visit(context) # Expand includes, substitutions, etc. - recursively_extract_entities( - included_ld.entities, context, nodes, composable_nodes, containers - ) + recursively_extract_entities(included_ld.entities, context, nodes, composable_nodes, containers) elif isinstance(entity, GroupAction): sub_entities = entity.get_sub_entities() - recursively_extract_entities( - sub_entities, context, nodes, composable_nodes, containers - ) + recursively_extract_entities(sub_entities, context, nodes, composable_nodes, containers) else: if not already_found(entity, nodes, composable_nodes, containers): if isinstance(entity, Node): - resolved_executable = resolve_substitutions( - context, entity._Node__node_executable - ) - resolved_name = resolve_substitutions( - context, entity._Node__node_name - ) - resolved_namespace = resolve_substitutions( - context, entity._Node__node_namespace - ) - resolved_package = resolve_substitutions( - context, entity._Node__package - ) + resolved_executable = resolve_substitutions(context, entity._Node__node_executable) + resolved_name = resolve_substitutions(context, entity._Node__node_name) + resolved_namespace = resolve_substitutions(context, entity._Node__node_namespace) + resolved_package = resolve_substitutions(context, entity._Node__package) + ns = resolved_namespace or entity._Node__namespace + nm = resolved_name or entity._Node__name print( - f"Found Node: {GREEN}{resolved_executable} {RESET} with the full name: {BLUE}{resolved_namespace or entity._Node__namespace}/{resolved_name or entity._Node__name} {RESET} within package{YELLOW}: {resolved_package}" + f"Found Node: {GREEN}{resolved_executable} {RESET} " + f"with the full name: {BLUE}{ns}/{nm} {RESET} " + f"within package{YELLOW}: {resolved_package}" ) nodes.append(entity) elif isinstance(entity, ComposableNode): @@ -86,9 +75,7 @@ def recursively_extract_entities( if hasattr(entity, "describe_sub_entities"): sub_entities = entity.describe_sub_entities() if sub_entities: - recursively_extract_entities( - sub_entities, context, nodes, composable_nodes, containers - ) + recursively_extract_entities(sub_entities, context, nodes, composable_nodes, containers) except LookupError: resolved_package = resolve_substitutions(context, entity._Node__package) print(f"{RED}package could not be found: {resolved_package}{RESET}") @@ -104,9 +91,8 @@ def already_found(entity, nodes, composable_nodes, containers): elif isinstance(entity, ComposableNode): if entity in composable_nodes: return True - elif isinstance(entity, ComposableNodeContainer): - if entity in containers: - return True + elif isinstance(entity, ComposableNodeContainer) and entity in containers: + return True return False @@ -127,10 +113,7 @@ def main(): composable_nodes = [] containers = [] - recursively_extract_entities( - ld.entities, context, nodes, composable_nodes, containers - ) - + recursively_extract_entities(ld.entities, context, nodes, composable_nodes, containers) if __name__ == "__main__": diff --git a/composer/model/__init__.py b/muto_composer/model/__init__.py similarity index 100% rename from composer/model/__init__.py rename to muto_composer/model/__init__.py diff --git a/composer/model/composable.py b/muto_composer/model/composable.py similarity index 59% rename from composer/model/composable.py rename to muto_composer/model/composable.py index fd3b6f0..8f75135 100644 --- a/composer/model/composable.py +++ b/muto_composer/model/composable.py @@ -12,7 +12,9 @@ # import os -import composer.model.node as node + +import muto_composer.model.node as node + class Container: def __init__(self, stack, manifest=None): @@ -21,14 +23,14 @@ def __init__(self, stack, manifest=None): self.stack = stack self.manifest = manifest - self.package = manifest.get('package', '') - self.executable = manifest.get('executable', '') - self.name = manifest.get('name', '') - self.namespace = manifest.get('namespace', os.getenv('MUTONS', default='')) - self.output = manifest.get('output', 'screen') - self.nodes = [node.Node(stack, nDef, self) for nDef in manifest.get('node', [])] - self.remap = manifest.get('remap', []) - self.action = manifest.get('action', '') + self.package = manifest.get("package", "") + self.executable = manifest.get("executable", "") + self.name = manifest.get("name", "") + self.namespace = manifest.get("namespace", os.getenv("MUTONS", default="")) + self.output = manifest.get("output", "screen") + self.nodes = [node.Node(stack, nDef, self) for nDef in manifest.get("node", [])] + self.remap = manifest.get("remap", []) + self.action = manifest.get("action", "") def toManifest(self): return { @@ -39,22 +41,23 @@ def toManifest(self): "node": [n.toManifest() for n in self.nodes], "output": self.output, "remap": self.remap, - "action": self.action + "action": self.action, } def resolve_namespace(self): - ns_prefix = '/' if not self.namespace.startswith('/') else '' - ns_suffix = '/' if not self.namespace.endswith('/') else '' + ns_prefix = "/" if not self.namespace.startswith("/") else "" + ns_suffix = "/" if not self.namespace.endswith("/") else "" return f"{ns_prefix}{self.namespace}{ns_suffix}{self.name}/" def __eq__(self, other): if not isinstance(other, Container): return False - return (self.package == other.package and - self.name == other.name and - self.namespace == other.namespace and - self.executable == other.executable) - + return ( + self.package == other.package + and self.name == other.name + and self.namespace == other.namespace + and self.executable == other.executable + ) def __hash__(self): return hash((self.package, self.name, self.namespace, self.executable)) diff --git a/composer/model/node.py b/muto_composer/model/node.py similarity index 57% rename from composer/model/node.py rename to muto_composer/model/node.py index f83aaa2..3777cbe 100644 --- a/composer/model/node.py +++ b/muto_composer/model/node.py @@ -13,39 +13,48 @@ import logging import os -import composer.model.param as param -from lifecycle_msgs.msg import Transition, State -from lifecycle_msgs.srv import GetState, GetAvailableTransitions, GetAvailableStates, ChangeState + import rclpy +from lifecycle_msgs.msg import Transition +from lifecycle_msgs.srv import ChangeState, GetAvailableStates, GetState + +import muto_composer.model.param as param logger = logging.getLogger(__name__) + class Node: - def __init__(self, stack, manifest={}, container=None): + def __init__(self, stack, manifest=None, container=None): + if manifest is None: + manifest = {} if manifest is None: manifest = {} self.stack = stack self.container = container self.manifest = manifest - self.env = manifest.get('env', []) - self.param = [param.Param(stack, pDef) for pDef in manifest.get('param', [])] - self.remap = manifest.get('remap', []) - self.pkg = manifest.get('pkg', '') - self.exec = manifest.get('exec', '') - self.plugin = manifest.get('plugin', '') - self.lifecycle = manifest.get('lifecycle', '') - self.name = manifest.get('name', '') - self.ros_args = manifest.get('ros_args', '') - self.args = stack.resolve_expression(manifest.get('args', '')) - self.namespace = manifest.get('namespace', os.getenv('MUTONS', '')) - self.launch_prefix = manifest.get('launch-prefix', None) - self.output = manifest.get('output', 'both') - self.iff = manifest.get('if', '') - self.unless = manifest.get('unless', '') - self.action = manifest.get('action', '') - self.ros_params = [{key: value} for p in self.param if isinstance(p.value, dict) for key, value in p.value.items() ] - self.remap_args = [(stack.resolve_expression(rm['from']), stack.resolve_expression(rm['to'])) for rm in self.remap] + self.env = manifest.get("env", []) + self.param = [param.Param(stack, pDef) for pDef in manifest.get("param", [])] + self.remap = manifest.get("remap", []) + self.pkg = manifest.get("pkg", "") + self.exec = manifest.get("exec", "") + self.plugin = manifest.get("plugin", "") + self.lifecycle = manifest.get("lifecycle", "") + self.name = manifest.get("name", "") + self.ros_args = manifest.get("ros_args", "") + self.args = stack.resolve_expression(manifest.get("args", "")) + self.namespace = manifest.get("namespace", os.getenv("MUTONS", "")) + self.launch_prefix = manifest.get("launch-prefix") + self.output = manifest.get("output", "both") + self.iff = manifest.get("if", "") + self.unless = manifest.get("unless", "") + self.action = manifest.get("action", "") + self.ros_params = [ + {key: value} for p in self.param if isinstance(p.value, dict) for key, value in p.value.items() + ] + self.remap_args = [ + (stack.resolve_expression(rm["from"]), stack.resolve_expression(rm["to"])) for rm in self.remap + ] def toManifest(self): """Converts the node object back into a manifest dictionary.""" @@ -65,16 +74,18 @@ def toManifest(self): "output": self.output, "if": self.iff, "unless": self.unless, - "action": self.action + "action": self.action, } - - def change_state(self, verbs=[]): + + def change_state(self, verbs=None): + if verbs is None: + verbs = [] if self.lifecycle: - temporary_node = rclpy.create_node('change_state_node') - state_cli = temporary_node.create_client(ChangeState, f'/{self.namespace}/{self.name}/change_state') + temporary_node = rclpy.create_node("change_state_node") + state_cli = temporary_node.create_client(ChangeState, f"/{self.namespace}/{self.name}/change_state") while not state_cli.wait_for_service(timeout_sec=1.0): - temporary_node.get_logger().warn('Lifecycle change state service not available. Waiting...') - + temporary_node.get_logger().warn("Lifecycle change state service not available. Waiting...") + for verb in verbs: request = ChangeState.Request() t = Transition() @@ -88,10 +99,10 @@ def change_state(self, verbs=[]): def get_state(self): if self.lifecycle: - temporary_node = rclpy.create_node('get_state_node') - state_cli = temporary_node.create_client(GetState, f'/{self.namespace}/{self.name}/get_state') + temporary_node = rclpy.create_node("get_state_node") + state_cli = temporary_node.create_client(GetState, f"/{self.namespace}/{self.name}/get_state") while not state_cli.wait_for_service(timeout_sec=1.0): - temporary_node.get_logger().warn('Lifecycle get state service not available. Waiting...') + temporary_node.get_logger().warn("Lifecycle get state service not available. Waiting...") request = GetState.Request() future = state_cli.call_async(request) rclpy.spin_until_future_complete(temporary_node, future, timeout_sec=3.0) @@ -102,10 +113,12 @@ def get_state(self): def get_available_states(self): if self.lifecycle: - temporary_node = rclpy.create_node('get_available_states_node') - state_cli = temporary_node.create_client(GetAvailableStates, f'/{self.namespace}/{self.name}/get_available_states') + temporary_node = rclpy.create_node("get_available_states_node") + state_cli = temporary_node.create_client( + GetAvailableStates, f"/{self.namespace}/{self.name}/get_available_states" + ) while not state_cli.wait_for_service(timeout_sec=1.0): - temporary_node.get_logger().warn('Lifecycle get_available_states service not available. Waiting...') + temporary_node.get_logger().warn("Lifecycle get_available_states service not available. Waiting...") request = GetAvailableStates.Request() response = GetAvailableStates.Response() future = state_cli.call_async(request) @@ -116,14 +129,13 @@ def get_available_states(self): else: logger.warning(f"{self.name} is Not a managed node") - def __eq__(self, other): """Checks if two Node objects are equal based on their attributes.""" return isinstance(other, Node) and all( - getattr(self, attr) == getattr(other, attr) for attr in [ - 'pkg', 'name', 'namespace', 'exec', 'plugin', 'args' - ]) + getattr(self, attr) == getattr(other, attr) + for attr in ["pkg", "name", "namespace", "exec", "plugin", "args"] + ) def __hash__(self): """Computes a hash based on certain attributes of the Node.""" - return hash((self.pkg, self.name, self.namespace, self.exec, self.plugin)) \ No newline at end of file + return hash((self.pkg, self.name, self.namespace, self.exec, self.plugin)) diff --git a/composer/model/param.py b/muto_composer/model/param.py similarity index 76% rename from composer/model/param.py rename to muto_composer/model/param.py index cdf43d6..7546733 100644 --- a/composer/model/param.py +++ b/muto_composer/model/param.py @@ -12,10 +12,11 @@ # import logging -import subprocess +import re import shlex +import subprocess + import yaml -import re logger = logging.getLogger(__name__) @@ -27,30 +28,31 @@ def __init__(self, stack, manifest=None): self.stack = stack self.manifest = manifest - self.name = manifest.get('name', '') + self.name = manifest.get("name", "") self.value = self._resolve_value(manifest, stack) - self.sep = manifest.get('sep', '') - self.from_file = manifest.get('from', '') - self.namespace = manifest.get('namespace', '/') - self.command = manifest.get('command', '') + self.sep = manifest.get("sep", "") + self.from_file = manifest.get("from", "") + self.namespace = manifest.get("namespace", "/") + self.command = manifest.get("command", "") def _resolve_value(self, manifest, stack): """Resolve the value of the parameter from various sources.""" - if 'from' in manifest: - return self._resolve_from_file(stack.resolve_expression(manifest['from'])) - if 'command' in manifest: - return self._execute_command(stack.resolve_expression(manifest['command'])) - return self._parse_value(manifest.get('value')) + if "from" in manifest: + return self._resolve_from_file(stack.resolve_expression(manifest["from"])) + if "command" in manifest: + return self._execute_command(stack.resolve_expression(manifest["command"])) + return self._parse_value(manifest.get("value")) def _resolve_from_file(self, filepath): """Fetch and return the content of the specified file.""" - with open(filepath, 'r') as file: + with open(filepath) as file: try: yaml_contents = yaml.safe_load(file) - # FIXME: Below pattern matches everything. So it will load every parameter in yaml without looking at the relevant node name. - pattern = re.compile(r'/.*?') - matching_key = next(key for key in yaml_contents.keys() if pattern.match(key)) - ros_parameters = yaml_contents.get(matching_key, {}).get('ros__parameters', {}) + # FIXME: Below pattern matches everything. So it will load every parameter + # in yaml without looking at the relevant node name. + pattern = re.compile(r"/.*?") + matching_key = next(key for key in yaml_contents if pattern.match(key)) + ros_parameters = yaml_contents.get(matching_key, {}).get("ros__parameters", {}) return ros_parameters except yaml.YAMLError as e: @@ -58,7 +60,6 @@ def _resolve_from_file(self, filepath): except Exception as e: logger.error(f"Failed to read from file '{filepath}': {e}") return None - def _execute_command(self, command): """Execute the specified command and return its output.""" @@ -71,9 +72,9 @@ def _execute_command(self, command): def _parse_value(self, value): """Parse the given value into the appropriate data type.""" if isinstance(value, str): - if value.lower() == 'true': + if value.lower() == "true": return True - if value.lower() == 'false': + if value.lower() == "false": return False try: return int(value) @@ -98,10 +99,10 @@ def toManifest(self): def __eq__(self, other): """Check equality based on the attributes of the Param instance.""" return isinstance(other, Param) and all( - getattr(self, attr) == getattr(other, attr) for attr in [ - 'name', 'value', 'from_file', 'namespace', 'command' - ]) + getattr(self, attr) == getattr(other, attr) + for attr in ["name", "value", "from_file", "namespace", "command"] + ) def __hash__(self): """Generate a hash value for this Param instance.""" - return hash((self.name, self.value, self.from_file, self.namespace, self.command)) \ No newline at end of file + return hash((self.name, self.value, self.from_file, self.namespace, self.command)) diff --git a/composer/model/stack.py b/muto_composer/model/stack.py similarity index 75% rename from composer/model/stack.py rename to muto_composer/model/stack.py index f0bde62..eb01694 100644 --- a/composer/model/stack.py +++ b/muto_composer/model/stack.py @@ -12,32 +12,33 @@ # import logging -import subprocess import os import re -import composer.model.node as node -import composer.model.param as param -import composer.model.composable as composable +import subprocess + import rclpy -from composer.introspection.introspector import Introspector +from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription -from launch_ros.actions import Node, LoadComposableNodes -from launch_ros.actions import ComposableNodeContainer +from launch_ros.actions import ComposableNodeContainer, LoadComposableNodes, Node from launch_ros.descriptions import ComposableNode -from ament_index_python.packages import get_package_share_directory + +import muto_composer.model.composable as composable +import muto_composer.model.node as node +import muto_composer.model.param as param +from muto_composer.introspection.introspector import Introspector logger = logging.getLogger(__name__) -NOACTION = 'none' # possibly PARAMACTION sometime in the future -STARTACTION = 'start' -STOPACTION = 'stop' -LOADACTION = 'load' +NOACTION = "none" # possibly PARAMACTION sometime in the future +STARTACTION = "start" +STOPACTION = "stop" +LOADACTION = "load" -class Stack(): +class Stack: """The class that contains all stack related operations (apply, kill, stack, merge etc.)""" - def __init__(self, manifest={}, parent=None): + def __init__(self, manifest=None, parent=None): """Initialize the Stack object. Args: @@ -45,13 +46,15 @@ def __init__(self, manifest={}, parent=None): parent (object, optional): The parent stack object. Defaults to None. """ + if manifest is None: + manifest = {} self.manifest = manifest self.parent = parent - self.name = manifest.get('name', '') - self.context = manifest.get('context', '') - self.stackId = manifest.get('stackId', '') - self.param = manifest.get('param', []) - self.arg = self.resolve_args(manifest.get('arg', [])) + self.name = manifest.get("name", "") + self.context = manifest.get("context", "") + self.stackId = manifest.get("stackId", "") + self.param = manifest.get("param", []) + self.arg = self.resolve_args(manifest.get("arg", [])) params = [] for pDef in self.param: @@ -64,15 +67,15 @@ def initialize(self): """Initialize the stack elements (nodes, composable nodes, parameters etc.)""" self.stack = [] - referenced_stacks = self.manifest.get('stack', []) + self.manifest.get("stack", []) self.node = [] - for nDef in self.manifest.get('node', []): + for nDef in self.manifest.get("node", []): sn = node.Node(self, nDef) self.node.append(sn) self.composable = [] - for cDef in self.manifest.get('composable', []): + for cDef in self.manifest.get("composable", []): sn = composable.Container(self, cDef) self.composable.append(sn) @@ -103,17 +106,17 @@ def compare_composable(self, other): """ current_composables = {f"{c.namespace}/{c.name}": c for c in self.flatten_composable([])} other_composables = {f"{c.namespace}/{c.name}": c for c in other.flatten_composable([])} - + common_keys = current_composables.keys() & other_composables.keys() added_keys = other_composables.keys() - current_composables.keys() removed_keys = current_composables.keys() - other_composables.keys() - + common = [current_composables[key] for key in common_keys] added = [other_composables[key] for key in added_keys] removed = [current_composables[key] for key in removed_keys] - + return common, added, removed - + def flatten_nodes(self, list): """Flatten the nested structure of nodes in the stack. @@ -130,7 +133,7 @@ def flatten_nodes(self, list): s.flatten_nodes(list) return list except Exception as e: - logger.error(f'Exception occured in flatten_nodes: {e}') + logger.error(f"Exception occured in flatten_nodes: {e}") def flatten_composable(self, list): """Flatten the nested structure of composable nodes in the stack. @@ -149,7 +152,7 @@ def flatten_composable(self, list): s.flatten_composable(list) return list except Exception as e: - logger.error(f'Exception occured in flatten_composable: {e}') + logger.error(f"Exception occured in flatten_composable: {e}") def calculate_ros_params_differences(self, current, other): """Calculate differences in ROS parameters between nodes of the current stack and another stack. @@ -165,8 +168,7 @@ def calculate_ros_params_differences(self, current, other): for node_i in current.node: for node_j in other.node: if node_i.exec == node_j.exec and node_i.pkg == node_j.pkg: - diff = self.compare_ros_params( - node_i.ros_params, node_j.ros_params) + diff = self.compare_ros_params(node_i.ros_params, node_j.ros_params) if diff: differences[(node_i.name, node_j.name)] = diff return differences @@ -186,10 +188,10 @@ def compare_ros_params(params1, params2): def params_to_flat_dict(params): flat_dict = {} - for param in params: - if isinstance(param, dict): - for key in param: - flat_dict[key] = param.get(key) + for p in params: + if isinstance(p, dict): + for key in p: + flat_dict[key] = p.get(key) return flat_dict dict_params1 = params_to_flat_dict(params1) @@ -202,7 +204,7 @@ def params_to_flat_dict(params): val2 = dict_params2.get(key, None) if val1 != val2: - diff_entry = {'key': key, 'in_node1': val1, 'in_node2': val2} + diff_entry = {"key": key, "in_node1": val1, "in_node2": val2} diff.append(diff_entry) return diff @@ -234,12 +236,12 @@ def _merge_attributes(self, merged, other): def _merge_nodes(self, merged, other): common, difference, added = self.compare_nodes(other) - for node in common: - node.action = NOACTION - for node in added: - node.action = STARTACTION - for node in difference: - node.action = STOPACTION + for n in common: + n.action = NOACTION + for n in added: + n.action = STARTACTION + for n in difference: + n.action = STOPACTION merged.node = common.union(added).union(difference) def _merge_composables(self, merged, other): @@ -268,33 +270,32 @@ def _merge_composables(self, merged, other): merged.composable.append(container) return merged - + def compare_and_mark_nodes(self, current_container, other_container, merged): current_nodes = {(n.namespace, n.name): n for n in current_container.nodes} other_nodes = {(n.namespace, n.name): n for n in other_container.nodes} - for key, node in other_nodes.items(): + for key, n in other_nodes.items(): if key not in current_nodes: - node.action = STARTACTION + n.action = STARTACTION else: - node.action = NOACTION + n.action = NOACTION - for key, node in current_nodes.items(): + for key, n in current_nodes.items(): if key not in other_nodes: - node.action = STOPACTION + n.action = STOPACTION else: - - if node.action != STARTACTION: - node.action = NOACTION + if n.action != STARTACTION: + n.action = NOACTION # Add processed nodes back into their respective containers processed_container = other_container if other_container in merged.composable else current_container - processed_container.nodes = list(current_nodes.values()) + [n for n in other_nodes.values() if n.action == STARTACTION] + processed_container.nodes = list(current_nodes.values()) + [ + n for n in other_nodes.values() if n.action == STARTACTION + ] if processed_container not in merged.composable: merged.composable.append(processed_container) - - def _merge_params(self, merged, other): other_params = {param.name: param.value for param in other.param} for pn, pv in other_params.items(): @@ -307,7 +308,7 @@ def get_active_nodes(self): Returns: list: A list of active nodes. """ - n = rclpy.create_node('get_active_nodes', enable_rosout=False) + n = rclpy.create_node("get_active_nodes", enable_rosout=False) n_list = n.get_node_names_and_namespaces() n.destroy_node() return n_list @@ -338,7 +339,7 @@ def kill_diff(self, launcher, stack): for exec_name, pid in e.items(): if n.exec in exec_name and n.action == STOPACTION: intrspc.kill(exec_name, pid) - + # Kill composables for container in stack.composable: for cn in container.nodes: @@ -356,20 +357,30 @@ def change_params_at_runtime(self, param_differences): try: for key, val in param_differences.items(): for i in range(len(val)): - subprocess.run(['ros2', 'param', 'set', str(key[0]), str( - val[i]['key']), str(val[i]['in_node1'])]) + subprocess.run( + [ + "ros2", + "param", + "set", + str(key[0]), + str(val[i]["key"]), + str(val[i]["in_node1"]), + ] + ) except Exception as e: - logger.error(f'Exception occurred while changing parameters at runtime: {e}') + logger.error(f"Exception occurred while changing parameters at runtime: {e}") def toShallowManifest(self): - manifest = {"name": self.name, - "context": self.context, - "stackId": self.stackId, - "param": [], - "arg": [], - "stack": [], - "composable": [], - "node": []} + manifest = { + "name": self.name, + "context": self.context, + "stackId": self.stackId, + "param": [], + "arg": [], + "stack": [], + "composable": [], + "node": [], + } return manifest def toManifest(self): @@ -400,11 +411,11 @@ def process_remaps(self, remaps_config): Returns: list: List of processed remaps. """ - return [(rmp['from'], rmp['to']) for rmp in remaps_config] if remaps_config else [] + return [(rmp["from"], rmp["to"]) for rmp in remaps_config] if remaps_config else [] def should_node_run(self, node, launcher): - """Check if a node should run. - This method clears the situation where a + """Check if a node should run. + This method clears the situation where a node has NOACTION but it isn't running NOACTION is meant to keep the common processes alive when switching stacks @@ -415,31 +426,25 @@ def should_node_run(self, node, launcher): Returns: bool: True if the node should run, False otherwise. """ - active_nodes = [(active[1] if active[1] != '/' else '') + - '/' + active[0] for active in launcher._active_nodes] - - should_node_run = f'{node.namespace}/{node.name}' not in active_nodes + active_nodes = [(active[1] if active[1] != "/" else "") + "/" + active[0] for active in launcher._active_nodes] + + should_node_run = f"{node.namespace}/{node.name}" not in active_nodes return should_node_run def load_common_composables(self, container, launch_description: LaunchDescription): """If there are common containers in stack composables, load them onto the existing container - Args: + Args: container (object): The container object """ node_desc = [] for cn in container.nodes: if cn.action == LOADACTION: logger.debug(f"LOADING {cn.namespace}/{cn.name}") - node_desc.append(ComposableNode( - package=cn.pkg, - name=cn.name, - namespace=cn.namespace, - plugin=cn.plugin - )) + node_desc.append(ComposableNode(package=cn.pkg, name=cn.name, namespace=cn.namespace, plugin=cn.plugin)) if node_desc: load_action = LoadComposableNodes( - target_container=f'{container.namespace}/{container.name}', + target_container=f"{container.namespace}/{container.name}", composable_node_descriptions=[node_desc], ) launch_description.add_action(load_action) @@ -452,8 +457,18 @@ def handle_composable_nodes(self, composable_containers, launch_description, lau launch_description (object): The launch description object. """ for c in composable_containers: - node_desc = [ComposableNode(package=cn.pkg, plugin=cn.plugin, name=cn.name, namespace=cn.namespace, parameters=cn.ros_params, remappings=self.process_remaps(cn.remap)) - for cn in c.nodes if cn.action == STARTACTION or (cn.action == NOACTION and self.should_node_run(cn, launcher))] + node_desc = [ + ComposableNode( + package=cn.pkg, + plugin=cn.plugin, + name=cn.name, + namespace=cn.namespace, + parameters=cn.ros_params, + remappings=self.process_remaps(cn.remap), + ) + for cn in c.nodes + if cn.action == STARTACTION or (cn.action == NOACTION and self.should_node_run(cn, launcher)) + ] if node_desc: # If node_desc is not empty container = ComposableNodeContainer( @@ -480,16 +495,18 @@ def handle_regular_nodes(self, nodes, launch_description, launcher): if action == "": action = NOACTION if action == STARTACTION or (action == NOACTION and self.should_node_run(n, launcher)): - launch_description.add_action(Node( - package=n.pkg, - executable=n.exec, - name=n.name, - namespace=n.namespace, - output=n.output, - parameters=n.ros_params, - arguments=n.args.split(), - remappings=self.process_remaps(n.remap) - )) + launch_description.add_action( + Node( + package=n.pkg, + executable=n.exec, + name=n.name, + namespace=n.namespace, + output=n.output, + parameters=n.ros_params, + arguments=n.args.split(), + remappings=self.process_remaps(n.remap), + ) + ) def handle_managed_nodes(self, nodes, verb): """Handle regular nodes during stack launching. @@ -512,18 +529,17 @@ def launch(self, launcher): launch_description = LaunchDescription() try: - self.handle_composable_nodes( - self.composable, launch_description, launcher) + self.handle_composable_nodes(self.composable, launch_description, launcher) self.handle_regular_nodes(self.node, launch_description, launcher) except Exception as e: - logger.error(f'Stack launching ended with exception: {e}') + logger.error(f"Stack launching ended with exception: {e}") launcher.start(launch_description) all_nodes = self.node + [cn for c in self.composable for cn in c.nodes] # After nodes are launched, take care of managed node actions - self.handle_managed_nodes(all_nodes, verb='start') + self.handle_managed_nodes(all_nodes, verb="start") def apply(self, launcher): """Apply the stack. @@ -544,7 +560,7 @@ def resolve_expression(self, value=""): str: The resolved value. """ value = str(value) - expressions = re.findall(r'\$\(([\s0-9a-zA-Z_-]+)\)', value) + expressions = re.findall(r"\$\(([\s0-9a-zA-Z_-]+)\)", value) result = value for expression in expressions: @@ -552,46 +568,48 @@ def resolve_expression(self, value=""): resolved_value = "" try: - if expr == 'find': + if expr == "find": resolved_value = get_package_share_directory(var) - elif expr == 'env': + elif expr == "env": resolved_value = os.environ[var] - elif expr == 'optenv': - resolved_value = os.environ.get(var, '') - elif expr == 'arg': + elif expr == "optenv": + resolved_value = os.environ.get(var, "") + elif expr == "arg": arg_value = self.arg.get(var) if arg_value is not None: - resolved_value = arg_value['value'] - elif expr == 'eval': - raise NotImplementedError( - f"Value: {value} is not supported in Muto") + resolved_value = arg_value["value"] + elif expr == "eval": + raise NotImplementedError(f"Value: {value} is not supported in Muto") else: continue - result = re.sub( - r'\$\(' + re.escape(expression) + r'\)', resolved_value, result, count=1) - except KeyError: - raise Exception(f"{var} does not exist", 'param') + result = re.sub(r"\$\(" + re.escape(expression) + r"\)", resolved_value, result, count=1) + except KeyError as exc: + raise Exception(f"{var} does not exist", "param") from exc except Exception as e: - logger.error(f'Exception occurred: {e}') + logger.error(f"Exception occurred: {e}") return result - def resolve_param_expression(self, param={}): - name = '' + def resolve_param_expression(self, param=None): + if param is None: + param = {} + name = "" value = None valKey = None - for k in param.keys(): - if 'name' == k: - name = param['name'] + for k in param: + if k == "name": + name = param["name"] else: value = param[k] valKey = k - if not valKey is None: + if valKey is not None: return (name, valKey, self.resolve_expression(value)) return None - def resolve_args(self, array=[]): + def resolve_args(self, array=None): + if array is None: + array = [] result = {} self.arg = {} for item in array: @@ -601,4 +619,4 @@ def resolve_args(self, array=[]): result[name] = p self.arg[name] = p - return result \ No newline at end of file + return result diff --git a/composer/muto_composer.py b/muto_composer/muto_composer.py similarity index 81% rename from composer/muto_composer.py rename to muto_composer/muto_composer.py index 0c606b4..7285d44 100644 --- a/composer/muto_composer.py +++ b/muto_composer/muto_composer.py @@ -16,24 +16,23 @@ Coordinates subsystems to handle stack deployment orchestration. """ -import os import json -from typing import Optional, Dict, Any +from typing import Any + import rclpy +from muto_msgs.msg import MutoAction from rclpy.node import Node from std_msgs.msg import String -from muto_msgs.msg import MutoAction -from rclpy.callback_groups import ReentrantCallbackGroup -from composer.events import EventBus, EventType, StackRequestEvent, ProcessCrashedEvent -from composer.subsystems.message_handler import MessageHandler -from composer.subsystems.stack_manager import StackManager -from composer.subsystems.orchestration_manager import OrchestrationManager -from composer.subsystems.pipeline_engine import PipelineEngine -from composer.subsystems.digital_twin_integration import DigitalTwinIntegration + +from muto_composer.events import EventBus, EventType, ProcessCrashedEvent, StackRequestEvent +from muto_composer.subsystems.digital_twin_integration import DigitalTwinIntegration +from muto_composer.subsystems.message_handler import MessageHandler +from muto_composer.subsystems.orchestration_manager import OrchestrationManager +from muto_composer.subsystems.pipeline_engine import PipelineEngine +from muto_composer.subsystems.stack_manager import StackManager # Legacy imports for test compatibility -from composer.workflow.pipeline import Pipeline -from composer.utils.stack_parser import create_stack_parser +from muto_composer.utils.stack_parser import create_stack_parser class MutoComposer(Node): @@ -41,80 +40,64 @@ class MutoComposer(Node): Refactored Muto Composer using modular, event-driven architecture. Coordinates subsystems to handle stack deployment orchestration. """ - + def __init__(self): super().__init__("muto_composer") - + # Initialize configuration parameters self.declare_parameter("stack_topic", "stack") self.declare_parameter("twin_url", "sandbox.composiv.ai") self.declare_parameter("namespace", "org.eclipse.muto.sandbox") self.declare_parameter("name", "example-01") - + # Extract parameter values self.twin_url = self.get_parameter("twin_url").get_parameter_value().string_value self.twin_namespace = self.get_parameter("namespace").get_parameter_value().string_value self.name = self.get_parameter("name").get_parameter_value().string_value self.next_stack_topic = self.get_parameter("stack_topic").get_parameter_value().string_value - + # Initialize event bus for subsystem communication self.event_bus = EventBus() self.event_bus.set_logger(self.get_logger()) - + # Initialize all subsystems with dependency injection self._initialize_subsystems() - + # Set up ROS 2 interfaces after subsystems are ready self._setup_ros_interfaces() - + # Subscribe to relevant events for coordination self._subscribe_to_events() - + # Legacy attributes for test compatibility self.pipelines = {} # Deprecated - now handled by PipelineEngine self.current_stack = None # Deprecated - now handled by StackManager self.next_stack = None # Deprecated - now handled by StackManager self.method = None # Deprecated - now extracted from events self.stack_parser = create_stack_parser(self.get_logger()) # For test compatibility - + self.get_logger().info("Refactored MutoComposer initialized successfully") - + def _initialize_subsystems(self): """Initialize all subsystems in correct dependency order.""" try: # Initialize core subsystems - self.message_handler = MessageHandler( - node=self, - event_bus=self.event_bus - ) - - self.digital_twin = DigitalTwinIntegration( - node=self, - event_bus=self.event_bus, - logger=self.get_logger() - ) - - self.stack_manager = StackManager( - event_bus=self.event_bus, - logger=self.get_logger() - ) - - self.orchestration_manager = OrchestrationManager( - event_bus=self.event_bus, - logger=self.get_logger() - ) - - self.pipeline_engine = PipelineEngine( - event_bus=self.event_bus, - logger=self.get_logger() - ) - + self.message_handler = MessageHandler(node=self, event_bus=self.event_bus) + + self.digital_twin = DigitalTwinIntegration(node=self, event_bus=self.event_bus, logger=self.get_logger()) + + self.stack_manager = StackManager(event_bus=self.event_bus, logger=self.get_logger()) + + self.orchestration_manager = OrchestrationManager(event_bus=self.event_bus, logger=self.get_logger()) + + self.pipeline_engine = PipelineEngine(event_bus=self.event_bus, logger=self.get_logger()) + self.get_logger().info("All subsystems initialized successfully") - + except Exception as e: self.get_logger().error(f"Failed to initialize subsystems: {e}") raise - + def _setup_ros_interfaces(self): """Set up ROS 2 publishers and subscribers.""" try: @@ -123,10 +106,7 @@ def _setup_ros_interfaces(self): # Subscribe to process crash notifications from launch_plugin self._crash_subscription = self.create_subscription( - String, - "launch_plugin/process_crashed", - self._handle_process_crash_notification, - 10 + String, "launch_plugin/process_crashed", self._handle_process_crash_notification, 10 ) self.get_logger().info("ROS 2 interfaces set up successfully") @@ -134,27 +114,21 @@ def _setup_ros_interfaces(self): except Exception as e: self.get_logger().error(f"Failed to set up ROS interfaces: {e}") raise - + def _subscribe_to_events(self): """Subscribe to coordination events from subsystems.""" try: # Subscribe to events that require high-level coordination - self.event_bus.subscribe( - EventType.PIPELINE_COMPLETED, - self._handle_pipeline_completed - ) - - self.event_bus.subscribe( - EventType.PIPELINE_FAILED, - self._handle_pipeline_failed - ) - + self.event_bus.subscribe(EventType.PIPELINE_COMPLETED, self._handle_pipeline_completed) + + self.event_bus.subscribe(EventType.PIPELINE_FAILED, self._handle_pipeline_failed) + self.get_logger().debug("Event subscriptions set up successfully") - + except Exception as e: self.get_logger().error(f"Failed to set up event subscriptions: {e}") raise - + def on_stack_callback(self, stack_msg: MutoAction): """ Main entry point for handling incoming MutoAction messages. @@ -162,33 +136,33 @@ def on_stack_callback(self, stack_msg: MutoAction): """ try: self.get_logger().info(f"Received MutoAction: {stack_msg.method}") - + # Parse payload payload = json.loads(stack_msg.payload) - + # Determine stack name (extract from payload or use default) stack_name = self._extract_stack_name(payload) - + # Create and publish stack request event stack_request = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="muto_composer", stack_name=stack_name, action=stack_msg.method, - stack_payload=payload + stack_payload=payload, ) - + # Publish to event bus for subsystem processing self.event_bus.publish_sync(stack_request) - + self.get_logger().info(f"Stack request published for processing: {stack_name}") - + except json.JSONDecodeError as e: self.get_logger().error(f"Invalid JSON in payload: {e}") except Exception as e: self.get_logger().error(f"Error handling stack callback: {e}") - - def _extract_stack_name(self, payload: Dict[str, Any]) -> str: + + def _extract_stack_name(self, payload: dict[str, Any]) -> str: """Extract stack name from payload or generate default.""" try: # Check for value.stackId pattern @@ -196,37 +170,37 @@ def _extract_stack_name(self, payload: Dict[str, Any]) -> str: stack_id = payload["value"].get("stackId", "") if stack_id: return stack_id - + # Check for direct stackId stack_id = payload.get("stackId", "") if stack_id: return stack_id - + # Check for metadata name if "metadata" in payload: name = payload["metadata"].get("name", "") if name: return name - + # Default naming return f"{self.twin_namespace}:{self.name}" - + except Exception as e: self.get_logger().warning(f"Error extracting stack name: {e}") return f"{self.twin_namespace}:{self.name}" - + def _handle_pipeline_completed(self, event): """Handle pipeline completion for high-level coordination.""" try: self.get_logger().info(f"Pipeline completed: {event.pipeline_name}") - + # Log completion details instead of publishing deprecated state - if hasattr(event, 'final_result') and event.final_result: + if hasattr(event, "final_result") and event.final_result: self.get_logger().info(f"Pipeline result keys: {list(event.final_result.keys())}") - + except Exception as e: self.get_logger().error(f"Error handling pipeline completion: {e}") - + def _handle_pipeline_failed(self, event): """Handle pipeline failure for error recovery.""" try: @@ -256,7 +230,7 @@ def _handle_process_crash_notification(self, msg: String): exit_code=crash_data.get("exit_code", -1), stack_name=crash_data.get("stack_name", ""), error_message=crash_data.get("error_message", ""), - process_output=crash_data.get("process_output", "") + process_output=crash_data.get("process_output", ""), ) # Publish to event bus for subsystem processing (triggers rollback) @@ -270,73 +244,79 @@ def _handle_process_crash_notification(self, msg: String): self.get_logger().error(f"Error handling process crash notification: {e}") # Legacy interface methods for backward compatibility - def pipeline_execute(self, pipeline_name: str, additional_context: Optional[Dict] = None, - stack_manifest: Optional[Dict] = None): + def pipeline_execute( + self, + pipeline_name: str, + additional_context: dict | None = None, + stack_manifest: dict | None = None, + ): """Legacy interface: Execute a pipeline directly.""" try: self.get_logger().info(f"Legacy pipeline execution request: {pipeline_name}") self.pipeline_engine.execute_pipeline(pipeline_name, additional_context, stack_manifest) except Exception as e: self.get_logger().error(f"Error in legacy pipeline execution: {e}") - + # Deprecated methods for test compatibility - marked for removal def bootstrap(self): """DEPRECATED: Bootstrap method for test compatibility.""" self.get_logger().warning("bootstrap() method is deprecated") - + def activate(self, future): """DEPRECATED: Activate method for test compatibility.""" self.get_logger().warning("activate() method is deprecated") - + def set_stack_done_callback(self, future): """DEPRECATED: Set stack done callback for test compatibility.""" self.get_logger().warning("set_stack_done_callback() method is deprecated") - + def get_stack_done_callback(self, future): """DEPRECATED: Get stack done callback for test compatibility.""" self.get_logger().warning("get_stack_done_callback() method is deprecated") - + def determine_execution_path(self): """DEPRECATED: Execution path determination moved to OrchestrationManager.""" - self.get_logger().warning("determine_execution_path() method is deprecated - now handled by OrchestrationManager") - + self.get_logger().warning( + "determine_execution_path() method is deprecated - now handled by OrchestrationManager" + ) + def resolve_expression(self, value: str = "") -> str: """DEPRECATED: Expression resolution moved to StackProcessor.""" self.get_logger().warning("resolve_expression() method is deprecated - now handled by StackProcessor") return value # Return unchanged for compatibility - + def merge(self, current_stack: dict, next_stack: dict) -> dict: """DEPRECATED: Stack merging moved to StackProcessor.""" self.get_logger().warning("merge() method is deprecated - now handled by StackProcessor") return next_stack # Return next_stack for basic compatibility - + def load_pipeline_config(self, file_path: str): """DEPRECATED: Pipeline configuration loading moved to PipelineEngine.""" self.get_logger().warning("load_pipeline_config() method is deprecated - now handled by PipelineEngine") return {"pipelines": []} - + def init_pipelines(self, pipeline_config): """DEPRECATED: Pipeline initialization moved to PipelineEngine.""" self.get_logger().warning("init_pipelines() method is deprecated - now handled by PipelineEngine") - + # Publisher methods for test compatibility def publish_current_stack(self, stack: str): """DEPRECATED: Current stack publishing removed per guidelines.""" self.get_logger().warning("publish_current_stack() method is deprecated") - + def publish_next_stack(self, stack: str): """DEPRECATED: Next stack publishing removed per guidelines.""" self.get_logger().warning("publish_next_stack() method is deprecated") - + def publish_raw_stack(self, stack: str): """DEPRECATED: Raw stack publishing removed per guidelines.""" self.get_logger().warning("publish_raw_stack() method is deprecated") - + def parse_payload(self, payload): - """DEPRECATED: Payload parsing moved to StackProcessor.""" + """DEPRECATED: Payload parsing moved to StackProcessor.""" self.get_logger().warning("parse_payload() method is deprecated - now handled by StackProcessor") return payload - + def extract_stack_from_solution(self, solution): """DEPRECATED: Stack extraction functionality moved to StackProcessor.""" self.get_logger().warning("extract_stack_from_solution() method is deprecated") diff --git a/composer/plugins/__init__.py b/muto_composer/plugins/__init__.py similarity index 66% rename from composer/plugins/__init__.py rename to muto_composer/plugins/__init__.py index 1a291e8..b965a13 100644 --- a/composer/plugins/__init__.py +++ b/muto_composer/plugins/__init__.py @@ -11,12 +11,11 @@ # Composiv.ai - initial API and implementation # -from .base_plugin import StackTypeHandler, BasePlugin, StackContext, StackOperation +from .base_plugin import BasePlugin, StackContext, StackOperation, StackTypeHandler __all__ = [ - - 'StackTypeHandler', - 'BasePlugin', - 'StackContext', - 'StackOperation', + "StackTypeHandler", + "BasePlugin", + "StackContext", + "StackOperation", ] diff --git a/composer/plugins/base_plugin.py b/muto_composer/plugins/base_plugin.py similarity index 76% rename from composer/plugins/base_plugin.py rename to muto_composer/plugins/base_plugin.py index 4683470..e098f18 100644 --- a/composer/plugins/base_plugin.py +++ b/muto_composer/plugins/base_plugin.py @@ -15,20 +15,22 @@ import json import os from abc import ABC, abstractmethod -from typing import Dict, Any, Optional from dataclasses import dataclass from enum import Enum from threading import Event +from typing import Any import rclpy -from rclpy.node import Node -from composer.utils.stack_parser import StackParser -from composer.utils.paths import WORKSPACES_PATH, ARTIFACT_STATE_FILE from muto_msgs.srv import CoreTwin +from rclpy.node import Node + +from muto_composer.utils.paths import WORKSPACES_PATH +from muto_composer.utils.stack_parser import StackParser class StackOperation(Enum): """Enumeration of stack operations.""" + START = "start" KILL = "kill" APPLY = "apply" @@ -39,52 +41,50 @@ class StackOperation(Enum): @dataclass class StackContext: """Context object passed during double dispatch.""" - stack_data: Dict[str, Any] - metadata: Dict[str, Any] + + stack_data: dict[str, Any] + metadata: dict[str, Any] operation: StackOperation - name: Optional[str] = None - logger: Optional[Any] = None + name: str | None = None + logger: Any | None = None # Additional context data can be added here - workspace_path: Optional[str] = None - launcher: Optional[Any] = None + workspace_path: str | None = None + launcher: Any | None = None hash: str = None - class BasePlugin(Node): """Base class for stack plugins implementing the Plugin interface.""" def __init__(self, node_name: str): - from composer.stack_handlers.registry import StackTypeRegistry + from muto_composer.stack_handlers.registry import StackTypeRegistry + Node.__init__(self, node_name) - self._stack_definition_client = self.create_client( - CoreTwin, "/muto/core_twin/get_stack_definition" - ) + self._stack_definition_client = self.create_client(CoreTwin, "/muto/core_twin/get_stack_definition") self.stack_registry = StackTypeRegistry(self, self.get_logger()) self.stack_registry.discover_and_register_handlers() self.stack_parser = StackParser(self.get_logger()) - # Plugin interface implementation - single accept method - + def _get_stack_name(self, stack_dict): """ - Get the stack name from a stack dictionary, checking metadata.name first, then name, then defaulting to 'default'. - + Get the stack name from a stack dictionary, checking metadata.name first, + then name, then defaulting to 'default'. + Args: stack_dict (dict): The stack dictionary - + Returns: str: The stack name """ if not stack_dict or not isinstance(stack_dict, dict): return "default" - + metadata = stack_dict.get("metadata", {}) return metadata.get("name", stack_dict.get("name", "default")) - - - def find_file(self, ws_path: str, file_name: str) -> Optional[str]: + + def find_file(self, ws_path: str, file_name: str) -> str | None: """ Helper method to find a file in the workspace path. @@ -111,7 +111,6 @@ def find_file(self, ws_path: str, file_name: str) -> Optional[str]: self.get_logger().warning(f"File '{file_name}' not found under '{ws_path}'.") return None - def find_stack_handler(self, request): """ Get the stack handler for the current context. @@ -143,7 +142,7 @@ def find_stack_handler(self, request): WORKSPACES_PATH, stack_name.replace(" ", "_"), ) - + # Create context for double dispatch context = StackContext( stack_data=current_stack, @@ -152,15 +151,15 @@ def find_stack_handler(self, request): name=current_stack.get("name", "default"), logger=self.get_logger(), workspace_path=workspace_path, - hash=hash + hash=hash, ) return handler, context except Exception as e: self.get_logger().error(f"Error creating stack_handler: {e}") - return None,None - return None,None + return None, None + return None, None - def _fetch_stack_manifest(self, stack_id: str) -> Optional[Dict[str, Any]]: + def _fetch_stack_manifest(self, stack_id: str) -> dict[str, Any] | None: """ Retrieve a stack manifest from CoreTwin using the provided stack ID. """ @@ -172,9 +171,7 @@ def _fetch_stack_manifest(self, stack_id: str) -> Optional[Dict[str, Any]]: return None if not self._stack_definition_client.wait_for_service(timeout_sec=0.5): - self.get_logger().warning( - "CoreTwin get_stack_definition service is not available." - ) + self.get_logger().warning("CoreTwin get_stack_definition service is not available.") return None request = CoreTwin.Request() @@ -190,48 +187,38 @@ def _cb(fut): completed = result_holder["event"].wait(timeout=5.0) if not completed: - self.get_logger().warning( - f"Timeout reached while waiting for stack manifest: {stack_id}" - ) + self.get_logger().warning(f"Timeout reached while waiting for stack manifest: {stack_id}") return None return result_holder["manifest"] except Exception as exc: - self.get_logger().error( - f"Error fetching stack manifest for stackId '{stack_id}': {exc}" - ) + self.get_logger().error(f"Error fetching stack manifest for stackId '{stack_id}': {exc}") return None - def _handle_twin_response(self, future: rclpy.Future, holder: Dict[str, Any], stack_id: str): + def _handle_twin_response(self, future: rclpy.Future, holder: dict[str, Any], stack_id: str): """ Callback to process CoreTwin responses without blocking execution. """ try: result = future.result() if not result or not getattr(result, "output", None): - self.get_logger().warning( - f"CoreTwin returned an empty manifest for stackId '{stack_id}'." - ) + self.get_logger().warning(f"CoreTwin returned an empty manifest for stackId '{stack_id}'.") holder["manifest"] = None else: try: holder["manifest"] = json.loads(result.output) except json.JSONDecodeError as json_err: - self.get_logger().error( - f"Failed to decode manifest for stackId '{stack_id}': {json_err}" - ) + self.get_logger().error(f"Failed to decode manifest for stackId '{stack_id}': {json_err}") holder["manifest"] = None except Exception as exc: - self.get_logger().error( - f"Error processing manifest response for stackId '{stack_id}': {exc}" - ) + self.get_logger().error(f"Error processing manifest response for stackId '{stack_id}': {exc}") holder["manifest"] = None finally: holder["event"].set() - def _is_manifest_payload(self, stack_dict: Dict[str, Any]) -> bool: + def _is_manifest_payload(self, stack_dict: dict[str, Any]) -> bool: """ Determine whether the provided dictionary already looks like a full stack manifest. """ @@ -245,26 +232,21 @@ def _is_manifest_payload(self, stack_dict: Dict[str, Any]) -> bool: def _safely_parse_stack(self, stack_string): """ Safely parse a stack string to JSON. Returns dictionary if valid JSON, None otherwise. - + Args: stack_string (str): The stack string to parse - + Returns: dict or None: Parsed JSON dictionary or None if parsing fails """ if not stack_string: return None - + try: - if isinstance(stack_string, dict): - parsed = stack_string - else: - parsed = json.loads(stack_string) + parsed = stack_string if isinstance(stack_string, dict) else json.loads(stack_string) if not isinstance(parsed, dict): - self.get_logger().warning( - f"Stack string parsed to non-dict type: {type(parsed)}" - ) + self.get_logger().warning(f"Stack string parsed to non-dict type: {type(parsed)}") return None if self._is_manifest_payload(parsed): @@ -286,16 +268,14 @@ def _safely_parse_stack(self, stack_string): return manifest # Fallback to parsed data if manifest retrieval fails - self.get_logger().warning( - f"Falling back to stack reference for stackId '{stack_id}'." - ) + self.get_logger().warning(f"Falling back to stack reference for stackId '{stack_id}'.") return parsed except (json.JSONDecodeError, TypeError) as e: self.get_logger().warning(f"Failed to parse stack string as JSON: {e}") return None - def _validate_stack_manifest(self, stack: Dict[str, Any]) -> bool: + def _validate_stack_manifest(self, stack: dict[str, Any]) -> bool: """ Validate that the stack manifest is well-formed before processing. @@ -317,16 +297,11 @@ def _validate_stack_manifest(self, stack: Dict[str, Any]) -> bool: # Validate that the content matches declared type if content_type == "stack/archive": if not stack.get("launch"): - self.get_logger().warning( - "Stack declares stack/archive but missing launch section" - ) - return False - elif content_type == "stack/json": - if not stack.get("launch"): - self.get_logger().warning( - "Stack declares stack/json but missing launch section" - ) + self.get_logger().warning("Stack declares stack/archive but missing launch section") return False + elif content_type == "stack/json" and not stack.get("launch"): + self.get_logger().warning("Stack declares stack/json but missing launch section") + return False return True @@ -336,12 +311,12 @@ class StackTypeHandler(ABC): Abstract base class for stack type handlers. Simplified with double dispatch pattern. """ - + @abstractmethod - def can_handle(self, payload: Dict[str, Any]) -> bool: + def can_handle(self, payload: dict[str, Any]) -> bool: """Determine if this handler can process the given payload.""" pass - + @abstractmethod def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, response) -> any: """ @@ -349,4 +324,3 @@ def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, re The handler processes the type-specific logic for STACK operations. """ pass - diff --git a/composer/plugins/compose_plugin.py b/muto_composer/plugins/compose_plugin.py similarity index 92% rename from composer/plugins/compose_plugin.py rename to muto_composer/plugins/compose_plugin.py index 93c5661..d59db61 100644 --- a/composer/plugins/compose_plugin.py +++ b/muto_composer/plugins/compose_plugin.py @@ -12,12 +12,14 @@ # import json -from .base_plugin import BasePlugin, StackOperation import rclpy -from std_msgs.msg import String from muto_msgs.msg import StackManifest from muto_msgs.srv import ComposePlugin +from std_msgs.msg import String + +from .base_plugin import BasePlugin, StackOperation + class MutoDefaultComposePlugin(BasePlugin): def __init__(self): @@ -27,13 +29,8 @@ def __init__(self): self.next_stack = None # To store the next stack if needed self.create_subscription(String, "raw_stack", self.handle_raw_stack, 10) - self.composed_stack_publisher = self.create_publisher( - StackManifest, "composed_stack", 10 - ) - self.compose_srv = self.create_service( - ComposePlugin, "muto_compose", self.handle_compose - ) - + self.composed_stack_publisher = self.create_publisher(StackManifest, "composed_stack", 10) + self.compose_srv = self.create_service(ComposePlugin, "muto_compose", self.handle_compose) def handle_raw_stack(self, stack_msg: String): """ @@ -46,10 +43,7 @@ def handle_raw_stack(self, stack_msg: String): except json.JSONDecodeError as e: self.get_logger().error(f"Failed to parse raw stack JSON: {e}") - - def handle_compose( - self, request: ComposePlugin.Request, response: ComposePlugin.Response - ): + def handle_compose(self, request: ComposePlugin.Request, response: ComposePlugin.Response): """ Service handler for composing the stack. Publishes the composed stack if 'start' is True. @@ -94,8 +88,6 @@ def handle_compose( response.output.current = request.input.current return response - - def publish_composed_stack(self): """ Publish the composed stack to the ROS environment. @@ -134,4 +126,4 @@ def main(): rclpy.spin(compose_plugin) compose_plugin.destroy_node() if rclpy.ok(): - rclpy.shutdown() \ No newline at end of file + rclpy.shutdown() diff --git a/composer/plugins/launch_plugin.py b/muto_composer/plugins/launch_plugin.py similarity index 84% rename from composer/plugins/launch_plugin.py rename to muto_composer/plugins/launch_plugin.py index f0a98ad..55ccccb 100644 --- a/composer/plugins/launch_plugin.py +++ b/muto_composer/plugins/launch_plugin.py @@ -11,20 +11,22 @@ # Composiv.ai - initial API and implementation # -import os -import json -import subprocess import asyncio import atexit -from typing import Dict, Union, Optional +import json +import os +import subprocess + import rclpy +from muto_msgs.msg import StackManifest +from muto_msgs.srv import CoreTwin, LaunchPlugin from rclpy.callback_groups import ReentrantCallbackGroup from std_msgs.msg import String -from muto_msgs.msg import StackManifest -from muto_msgs.srv import LaunchPlugin, CoreTwin -from composer.workflow.launcher import Ros2LaunchParent -from composer.utils.paths import WORKSPACES_PATH -from composer.utils.stack_parser import StackParser + +from muto_composer.utils.paths import WORKSPACES_PATH +from muto_composer.utils.stack_parser import StackParser +from muto_composer.workflow.launcher import Ros2LaunchParent + from .base_plugin import BasePlugin, StackContext, StackOperation @@ -58,15 +60,9 @@ class MutoDefaultLaunchPlugin(BasePlugin): def __init__(self): super().__init__("launch_plugin") - self.start_srv = self.create_service( - LaunchPlugin, "muto_start_stack", self.handle_start - ) - self.stop_srv = self.create_service( - LaunchPlugin, "muto_kill_stack", self.handle_kill - ) - self.apply_srv = self.create_service( - LaunchPlugin, "muto_apply_stack", self.handle_apply - ) + self.start_srv = self.create_service(LaunchPlugin, "muto_start_stack", self.handle_start) + self.stop_srv = self.create_service(LaunchPlugin, "muto_kill_stack", self.handle_kill) + self.apply_srv = self.create_service(LaunchPlugin, "muto_apply_stack", self.handle_apply) self.set_stack_cli = self.create_client(CoreTwin, "core_twin/set_current_stack") @@ -74,17 +70,13 @@ def __init__(self): # Track managed launchers by launch file for consistent lifecycle management # Supports both Ros2LaunchParent (for .launch.py) and ShellProcessWrapper (for .sh) - self._managed_launchers: Dict[str, Union[Ros2LaunchParent, ShellProcessWrapper]] = dict() + self._managed_launchers: dict[str, Ros2LaunchParent | ShellProcessWrapper] = dict() # Track stack name associated with each launcher for crash reporting - self._launcher_stack_names: Dict[str, str] = {} + self._launcher_stack_names: dict[str, str] = {} # Publisher for process crash notifications - self._crash_publisher = self.create_publisher( - String, - "launch_plugin/process_crashed", - 10 - ) + self._crash_publisher = self.create_publisher(String, "launch_plugin/process_crashed", 10) # Ensure a valid asyncio loop exists to avoid 'There is no current event loop' warnings try: @@ -92,20 +84,17 @@ def __init__(self): except RuntimeError: self.async_loop = asyncio.new_event_loop() asyncio.set_event_loop(self.async_loop) - self.timer = self.create_timer( - 0.1, self.run_async_loop, callback_group=ReentrantCallbackGroup() - ) + self.timer = self.create_timer(0.1, self.run_async_loop, callback_group=ReentrantCallbackGroup()) # Background process health monitor timer self._process_monitor_timer = self.create_timer( self.PROCESS_MONITOR_INTERVAL, self._monitor_processes, - callback_group=ReentrantCallbackGroup() + callback_group=ReentrantCallbackGroup(), ) atexit.register(self._cleanup_managed_launchers) - def destroy_node(self) -> bool: """Ensure launched processes are cleaned up when the node is destroyed.""" self.get_logger().info("Destroying launch_plugin node; terminating active launchers.") @@ -117,7 +106,6 @@ def run_async_loop(self): """Periodically step through the asyncio event loop.""" self.async_loop.stop() self.async_loop.run_forever() - def source_workspaces(self, current: StackManifest): """ @@ -140,9 +128,7 @@ def source_workspaces(self, current: StackManifest): # Get stack name - check metadata.name first, then name, then default stack_name = self._get_stack_name(current) - workspace_dir = os.path.join( - WORKSPACES_PATH, stack_name.replace(" ", "_") - ) + workspace_dir = os.path.join(WORKSPACES_PATH, stack_name.replace(" ", "_")) def source_script(name: str, script_path: str) -> None: self.get_logger().info(f"Sourcing: {name} | {script_path}") @@ -158,17 +144,11 @@ def source_script(name: str, script_path: str) -> None: text=True, ) env_output = result.stdout - env_vars = dict( - line.split("=", 1) - for line in env_output.splitlines() - if "=" in line - ) + env_vars = dict(line.split("=", 1) for line in env_output.splitlines() if "=" in line) os.environ.update(env_vars) self.get_logger().info(f"Sourced workspace: {name}") except subprocess.CalledProcessError as e: - self.get_logger().error( - f"Failed to source workspace '{name}': {e.stderr}" - ) + self.get_logger().error(f"Failed to source workspace '{name}': {e.stderr}") except Exception as e: self.get_logger().error(f"Error sourcing workspace '{name}': {e}") @@ -178,18 +158,14 @@ def source_script(name: str, script_path: str) -> None: if not sources: default_install = os.path.join(workspace_dir, "install", "setup.bash") if os.path.exists(default_install): - self.get_logger().info( - "No explicit source scripts provided; sourcing install/setup.bash" - ) + self.get_logger().info("No explicit source scripts provided; sourcing install/setup.bash") source_script("workspace_install", default_install) else: self.get_logger().debug( "No explicit source scripts provided and install/setup.bash not found; skipping sourcing." ) - def handle_start( - self, request: LaunchPlugin.Request, response: LaunchPlugin.Response - ): + def handle_start(self, request: LaunchPlugin.Request, response: LaunchPlugin.Response): """ Service handler for starting the stack using double dispatch pattern. @@ -230,17 +206,13 @@ def run_script(self, script_path: str): os.chmod(script_path, 0o755) try: - result = subprocess.run( - [script_path], check=True, capture_output=True, text=True - ) + result = subprocess.run([script_path], check=True, capture_output=True, text=True) self.get_logger().info(f"Script output: {result.stdout}") except subprocess.CalledProcessError as e: self.get_logger().error(f"Script failed with error: {e.stderr}") raise - def handle_kill( - self, request: LaunchPlugin.Request, response: LaunchPlugin.Response - ): + def handle_kill(self, request: LaunchPlugin.Request, response: LaunchPlugin.Response): """ Service handler for killing the stack using double dispatch pattern. @@ -252,9 +224,7 @@ def handle_kill( LaunchPlugin.Response: The response indicating success or failure. """ try: - self.get_logger().info( - f"Kill requested; current number of launched stacks={len(self._managed_launchers)}" - ) + self.get_logger().info(f"Kill requested; current number of launched stacks={len(self._managed_launchers)}") # Check if this is a kill-only payload (just stackId reference, not a full manifest) is_kill_only_payload = False @@ -290,7 +260,7 @@ def handle_kill( handler, context = self.find_stack_handler(request) if handler and context: context.operation = StackOperation.KILL - success = handler.apply_to_plugin(self, context, request=request, response=response) + handler.apply_to_plugin(self, context, request=request, response=response) response.success = True else: response.success = False @@ -304,9 +274,7 @@ def handle_kill( response.output.current = request.input.current return response - def handle_apply( - self, request: LaunchPlugin.Request, response: LaunchPlugin.Response - ): + def handle_apply(self, request: LaunchPlugin.Request, response: LaunchPlugin.Response): """ Service handler for applying the stack configuration using double dispatch pattern. @@ -324,7 +292,7 @@ def handle_apply( self.get_logger().info( f"Apply requested; current number of launched stacks={len(self._managed_launchers)}" ) - success = handler.apply_to_plugin(self, context, request, response) + handler.apply_to_plugin(self, context, request, response) response.success = True else: response.success = False @@ -334,10 +302,9 @@ def handle_apply( self.get_logger().error(f"Exception occurred during kill: {e}") response.err_msg = str(e) response.success = False - + response.output.current = request.input.current return response - def _launch_via_ros2(self, context: StackContext, launch_file: str) -> bool: """ @@ -356,7 +323,7 @@ def _launch_via_ros2(self, context: StackContext, launch_file: str) -> bool: return False # Determine launch method based on file extension - if full_launch_file.endswith('.sh'): + if full_launch_file.endswith(".sh"): return self._launch_via_shell(context, full_launch_file, launch_file) else: return self._launch_via_ros2_launch(context, full_launch_file, launch_file) @@ -384,10 +351,7 @@ def _launch_via_shell(self, context: StackContext, full_path: str, launch_file: try: # Build the command - source setup.bash if available, then run script - if os.path.exists(setup_bash): - command = f"source {setup_bash} && bash {full_path}" - else: - command = f"bash {full_path}" + command = f"source {setup_bash} && bash {full_path}" if os.path.exists(setup_bash) else f"bash {full_path}" # Start the process in the background process = subprocess.Popen( @@ -397,7 +361,7 @@ def _launch_via_shell(self, context: StackContext, full_path: str, launch_file: cwd=workspace_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - preexec_fn=os.setsid # Create new process group for cleanup + preexec_fn=os.setsid, # Create new process group for cleanup ) # Store process for lifecycle management (using a wrapper object) @@ -405,18 +369,16 @@ def _launch_via_shell(self, context: StackContext, full_path: str, launch_file: self._managed_launchers[launch_file] = wrapper # Track stack name for crash reporting - stack_name = getattr(context, 'stack_name', None) or os.path.basename(workspace_dir) + stack_name = getattr(context, "stack_name", None) or os.path.basename(workspace_dir) self._launcher_stack_names[launch_file] = stack_name - self.get_logger().info( - f"Shell script launched with PID {process.pid}: {launch_file} (stack: {stack_name})" - ) + self.get_logger().info(f"Shell script launched with PID {process.pid}: {launch_file} (stack: {stack_name})") self.get_logger().info("Process will be monitored by background watchdog") return True except Exception as exc: self.get_logger().error(f"Failed to start shell script: {exc}") - raise RuntimeError(f"Failed to start shell script: {exc}") + raise RuntimeError(f"Failed to start shell script: {exc}") from exc def _launch_via_ros2_launch(self, context: StackContext, full_path: str, launch_file: str) -> bool: """ @@ -442,9 +404,7 @@ def _launch_via_ros2_launch(self, context: StackContext, full_path: str, launch_ # Use the async method to launch the file async def _do_launch(): await launcher.launch_a_launch_file( - launch_file_path=full_path, - launch_file_arguments=[], - noninteractive=True + launch_file_path=full_path, launch_file_arguments=[], noninteractive=True ) # Schedule the launch on the async loop @@ -457,7 +417,7 @@ async def _do_launch(): except Exception as exc: self.get_logger().error(f"Failed to start launch via Ros2LaunchParent: {exc}") - raise RuntimeError(f"Failed to start launch process: {exc}") + raise RuntimeError(f"Failed to start launch process: {exc}") from exc def _source_workspace(self, setup_bash: str) -> None: """Source a workspace setup.bash file to update environment.""" @@ -471,11 +431,7 @@ def _source_workspace(self, setup_bash: str) -> None: check=True, text=True, ) - env_vars = dict( - line.split("=", 1) - for line in result.stdout.splitlines() - if "=" in line - ) + env_vars = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) os.environ.update(env_vars) self.get_logger().debug(f"Sourced workspace: {setup_bash}") except Exception as e: @@ -500,7 +456,7 @@ def _set_current_stack(self, stack_id: str, state: str = "unknown") -> bool: request = CoreTwin.Request() request.input = stack_id - future = self.set_stack_cli.call_async(request) + self.set_stack_cli.call_async(request) self.get_logger().info(f"Setting current stack to {stack_id} with state={state}") return True @@ -560,13 +516,11 @@ def _monitor_processes(self) -> None: stdout_output = "" try: if launcher.process.stdout: - stdout_output = launcher.process.stdout.read().decode('utf-8', errors='replace') + stdout_output = launcher.process.stdout.read().decode("utf-8", errors="replace") except Exception: pass - self.get_logger().error( - f"Process {launch_file} crashed unexpectedly (exit code {exit_code})" - ) + self.get_logger().error(f"Process {launch_file} crashed unexpectedly (exit code {exit_code})") # Publish crash notification self._publish_crash_notification( @@ -574,7 +528,7 @@ def _monitor_processes(self) -> None: exit_code=exit_code, stack_name=stack_name, error_message=f"Process exited with code {exit_code}", - process_output=stdout_output[-500:] if stdout_output else "" + process_output=stdout_output[-500:] if stdout_output else "", ) # Remove from managed launchers @@ -587,7 +541,7 @@ def _publish_crash_notification( exit_code: int, stack_name: str, error_message: str, - process_output: str = "" + process_output: str = "", ) -> None: """Publish a process crash notification to the crash topic.""" crash_data = { @@ -595,16 +549,14 @@ def _publish_crash_notification( "exit_code": exit_code, "stack_name": stack_name, "error_message": error_message, - "process_output": process_output + "process_output": process_output, } msg = String() msg.data = json.dumps(crash_data) self._crash_publisher.publish(msg) - self.get_logger().info( - f"Published crash notification for {process_name} (stack: {stack_name})" - ) + self.get_logger().info(f"Published crash notification for {process_name} (stack: {stack_name})") def main(): diff --git a/composer/plugins/provision_plugin.py b/muto_composer/plugins/provision_plugin.py similarity index 84% rename from composer/plugins/provision_plugin.py rename to muto_composer/plugins/provision_plugin.py index e75838e..54de456 100644 --- a/composer/plugins/provision_plugin.py +++ b/muto_composer/plugins/provision_plugin.py @@ -13,8 +13,8 @@ import rclpy from muto_msgs.srv import ProvisionPlugin + from .base_plugin import BasePlugin, StackOperation -from composer.utils.paths import WORKSPACES_PATH, ARTIFACT_STATE_FILE class MutoProvisionPlugin(BasePlugin): @@ -22,13 +22,9 @@ class MutoProvisionPlugin(BasePlugin): def __init__(self): super().__init__("provision_plugin") - self.provision_srv = self.create_service( - ProvisionPlugin, "muto_provision", self.handle_provision - ) + self.provision_srv = self.create_service(ProvisionPlugin, "muto_provision", self.handle_provision) - def handle_provision( - self, request: ProvisionPlugin.Request, response: ProvisionPlugin.Response - ): + def handle_provision(self, request: ProvisionPlugin.Request, response: ProvisionPlugin.Response): """Service handler to prepare the workspace using double dispatch pattern.""" handler, context = self.find_stack_handler(request) try: @@ -44,12 +40,11 @@ def handle_provision( self.get_logger().error(f"Exception: {e}") response.err_msg = f"Error: {e}" response.success = False - + response.output.current = request.input.current return response - def main(): rclpy.init() provision_plugin = MutoProvisionPlugin() diff --git a/composer/stack_handlers/__init__.py b/muto_composer/stack_handlers/__init__.py similarity index 83% rename from composer/stack_handlers/__init__.py rename to muto_composer/stack_handlers/__init__.py index c96fe90..d7d9ee4 100644 --- a/composer/stack_handlers/__init__.py +++ b/muto_composer/stack_handlers/__init__.py @@ -11,15 +11,14 @@ # Composiv.ai - initial API and implementation # -from .registry import StackTypeRegistry -from .json_handler import JsonStackHandler from .archive_handler import ArchiveStackHandler from .ditto_handler import DittoStackHandler +from .json_handler import JsonStackHandler +from .registry import StackTypeRegistry __all__ = [ - - 'StackTypeRegistry', - 'JsonStackHandler', - 'ArchiveStackHandler', - 'DittoStackHandler', + "StackTypeRegistry", + "JsonStackHandler", + "ArchiveStackHandler", + "DittoStackHandler", ] diff --git a/composer/stack_handlers/archive_handler.py b/muto_composer/stack_handlers/archive_handler.py similarity index 87% rename from composer/stack_handlers/archive_handler.py rename to muto_composer/stack_handlers/archive_handler.py index 2ec8754..798177b 100644 --- a/composer/stack_handlers/archive_handler.py +++ b/muto_composer/stack_handlers/archive_handler.py @@ -11,45 +11,51 @@ # Composiv.ai - initial API and implementation # -import os +from __future__ import annotations + import base64 import binascii +import hashlib import json -import requests +import os import shutil import subprocess import tarfile import tempfile import zipfile -import hashlib -from typing import Any, Dict +from typing import Any from urllib.parse import urlparse -from typing import Optional -from composer.plugins.base_plugin import BasePlugin, StackTypeHandler, StackContext, StackOperation -from composer.utils.paths import WORKSPACES_PATH, ARTIFACT_STATE_FILE +import requests +from muto_composer.plugins.base_plugin import ( + BasePlugin, + StackContext, + StackOperation, + StackTypeHandler, +) +from muto_composer.utils.paths import ARTIFACT_STATE_FILE, WORKSPACES_PATH class ArchiveStackHandler(StackTypeHandler): """Handler for stack/archive type stacks.""" - - def __init__(self, logger=None, ignored_packages: Optional[list[str]] = None): + + def __init__(self, logger=None, ignored_packages: list[str] | None = None): self.is_up_to_date = False self.logger = logger self.ignored_packages = ignored_packages if ignored_packages is not None else [] - def can_handle(self, payload: Dict[str, Any]) -> bool: + def can_handle(self, payload: dict[str, Any]) -> bool: """Check for stack/archive content_type in properly defined solution.""" if not isinstance(payload, dict): return False metadata = payload.get("metadata", {}) content_type = metadata.get("content_type", "") return content_type == "stack/archive" - + def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, response) -> bool: """Double dispatch: delegate to plugin's accept method.""" - + if context.operation == StackOperation.PROVISION: return self._provision_archive(context, plugin) elif context.operation == StackOperation.START: @@ -64,50 +70,46 @@ def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, re else: if self.logger: self.logger.warning(f"Unsupported operation for archive stack: {context.operation}") - return False - + return False + def _provision_archive(self, context: StackContext, plugin: BasePlugin) -> bool: try: self.from_archive(context) if not self.is_up_to_date: - self.logger.info( - "Workspace is NOT up-to-date. Updating..." - ) + self.logger.info("Workspace is NOT up-to-date. Updating...") self.install_dependencies(context) self.build_workspace(context) self.is_up_to_date = True else: - self.logger.info( - "Workspace is up-to-date. Skipping build and provisioning steps." - ) + self.logger.info("Workspace is up-to-date. Skipping build and provisioning steps.") return True except Exception as e: if self.logger: self.logger.error(f"Error in apply_to_plugin for archive stack: {e}") return False - + def _start_archive(self, context: StackContext, plugin: BasePlugin) -> bool: try: launch_data = context.stack_data.get("launch", {}) props = launch_data.get("properties", {}) launch_file = props.get("launch_file") - + if not launch_file: if self.logger: self.logger.error("No launch_file specified in archive stack properties") return False - + if not context.workspace_path: if self.logger: self.logger.error("No workspace_path specified for archive stack start") return False plugin._launch_via_ros2(context, launch_file) - + # Store the process in the context for later management # Note: This needs to be handled by the plugin for process lifecycle management return True - + except Exception as e: if self.logger: self.logger.error(f"Error starting archive stack: {e}") @@ -128,7 +130,7 @@ def _kill_archive(self, context: StackContext, plugin: BasePlugin) -> bool: if self.logger: self.logger.error(f"Error killing archive stack: {e}") return False - + def from_archive(self, context) -> None: """Download and extract an archive described in the stack manifest.""" manifest = context.stack_data @@ -140,7 +142,7 @@ def from_archive(self, context) -> None: if not workspace_dir: return - props = artifact.get("properties", {}) + props = artifact.get("properties", {}) base_state = { "type": "archive", "subdir": props.get("subdir"), @@ -159,9 +161,7 @@ def from_archive(self, context) -> None: self.is_up_to_date = False self._reset_workspace(workspace_dir) self._extract_archive(archive_path, workspace_dir) - self.logger.info( - f"Workspace contents after extraction: {os.listdir(workspace_dir)}" - ) + self.logger.info(f"Workspace contents after extraction: {os.listdir(workspace_dir)}") subdir = props.get("subdir") if subdir: @@ -169,47 +169,40 @@ def from_archive(self, context) -> None: if os.path.isdir(resolved_subdir): self._move_contents(resolved_subdir, workspace_dir) else: - self.logger.warning( - f"Artifact subdirectory '{subdir}' not found; continuing without flattening." - ) + self.logger.warning(f"Artifact subdirectory '{subdir}' not found; continuing without flattening.") elif props.get("flatten", True): self._flatten_single_directory(workspace_dir) self._write_artifact_state(workspace_dir, {**base_state, **state_info}) - def _artifact_state_path(self, workspace_dir: str) -> str: return os.path.join(workspace_dir, ARTIFACT_STATE_FILE) - def _load_artifact_state(self, workspace_dir: str) -> Dict[str, Any]: + def _load_artifact_state(self, workspace_dir: str) -> dict[str, Any]: state_path = self._artifact_state_path(workspace_dir) if not os.path.exists(state_path): return {} try: - with open(state_path, "r", encoding="utf-8") as state_file: + with open(state_path, encoding="utf-8") as state_file: return json.load(state_file) except (OSError, json.JSONDecodeError) as exc: - self.logger.warning( - f"Failed to read artifact state from {state_path}: {exc}" - ) + self.logger.warning(f"Failed to read artifact state from {state_path}: {exc}") return {} - def _write_artifact_state(self, workspace_dir: str, state: Dict[str, Any]) -> None: + def _write_artifact_state(self, workspace_dir: str, state: dict[str, Any]) -> None: state_path = self._artifact_state_path(workspace_dir) try: with open(state_path, "w", encoding="utf-8") as state_file: json.dump(state, state_file) except OSError as exc: - self.logger.warning( - f"Failed to persist artifact state to {state_path}: {exc}" - ) + self.logger.warning(f"Failed to persist artifact state to {state_path}: {exc}") def _reset_workspace(self, workspace_dir: str) -> None: if os.path.isdir(workspace_dir): shutil.rmtree(workspace_dir, ignore_errors=True) os.makedirs(workspace_dir, exist_ok=True) - def _prepare_archive(self, manifest: Dict[str, Any], temp_dir: str) -> tuple[str, Dict[str, Any]]: + def _prepare_archive(self, manifest: dict[str, Any], temp_dir: str) -> tuple[str, dict[str, Any]]: artifact = manifest.get("launch", {}) data_b64 = artifact.get("data") url = artifact.get("url") @@ -236,10 +229,12 @@ def _prepare_archive(self, manifest: Dict[str, Any], temp_dir: str) -> tuple[str self._verify_checksum(archive_path, checksum, algorithm) digest = hashlib.sha256(decoded_bytes).hexdigest() - metadata.update({ - "source": "inline", - "data_sha256": digest, - }) + metadata.update( + { + "source": "inline", + "data_sha256": digest, + } + ) return archive_path, metadata @@ -264,7 +259,7 @@ def _prepare_archive(self, manifest: Dict[str, Any], temp_dir: str) -> tuple[str ) as response: try: response.raise_for_status() - except requests.HTTPError as exc: + except requests.HTTPError: if response.status_code == 404 and headers.get("Accept") == "application/octet-stream": self.logger.warning( "404 when requesting archive with Accept=application/octet-stream; retrying with '*/*'." @@ -293,7 +288,7 @@ def _prepare_archive(self, manifest: Dict[str, Any], temp_dir: str) -> tuple[str if chunk: file_handle.write(chunk) except requests.RequestException as exc: - raise RuntimeError(f"Failed to download artifact from {url}: {exc}") + raise RuntimeError(f"Failed to download artifact from {url}: {exc}") from exc elif parsed.scheme in ("", "file"): source_path = url[7:] if parsed.scheme == "file" else url shutil.copy(source_path, archive_path) @@ -324,13 +319,11 @@ def _verify_checksum(self, file_path: str, expected: str, algorithm: str) -> Non for chunk in iter(lambda: file_handle.read(8192), b""): digest.update(chunk) except OSError as exc: - raise RuntimeError(f"Failed to read archive for checksum verification: {exc}") + raise RuntimeError(f"Failed to read archive for checksum verification: {exc}") from exc actual = digest.hexdigest() if actual.lower() != expected.lower(): - raise ValueError( - f"Checksum mismatch for {file_path}: expected {expected}, got {actual}" - ) + raise ValueError(f"Checksum mismatch for {file_path}: expected {expected}, got {actual}") def _extract_archive(self, archive_path: str, destination: str) -> None: if tarfile.is_tarfile(archive_path): @@ -371,9 +364,7 @@ def from_git(self, repo_url: str, branch: str = "main") -> None: return stack_name = self.current_stack.get("name", "default") - target_dir = os.path.join( - WORKSPACES_PATH, stack_name.replace(" ", "_") - ) + target_dir = os.path.join(WORKSPACES_PATH, stack_name.replace(" ", "_")) if os.path.exists(os.path.join(target_dir, ".git")): self.update_repository(target_dir, branch) @@ -450,18 +441,14 @@ def checkout_branch(self, repo_dir: str, branch: str): cwd=repo_dir, ) except subprocess.CalledProcessError: - self.logger.warning( - f"Branch '{branch}' not found. Falling back to 'main'." - ) + self.logger.warning(f"Branch '{branch}' not found. Falling back to 'main'.") subprocess.run( ["git", "checkout", "main"], check=True, cwd=repo_dir, ) - def checkout_and_check_submodules( - self, target_dir: str, branch: str = "main" - ) -> bool: + def checkout_and_check_submodules(self, target_dir: str, branch: str = "main") -> bool: """ Ensure all submodules are checked out to the specified branch and are up-to-date. @@ -503,18 +490,14 @@ def checkout_and_check_submodules( if local_commit != remote_commit: all_submodules_up_to_date = False - self.logger.info( - f"Submodule '{submodule}' is not up-to-date. Pulling changes." - ) + self.logger.info(f"Submodule '{submodule}' is not up-to-date. Pulling changes.") subprocess.run( ["git", "pull"], check=True, cwd=submodule_path, ) except subprocess.CalledProcessError: - self.logger.warning( - f"Submodule '{submodule}' is not properly set up." - ) + self.logger.warning(f"Submodule '{submodule}' is not properly set up.") all_submodules_up_to_date = False except subprocess.CalledProcessError as e: @@ -562,9 +545,7 @@ def clean_build_workspace(self, context): dir_path = os.path.join(self.get_workspace_dir(context), dir_name) if os.path.exists(dir_path): shutil.rmtree(dir_path) - self.logger.info( - f"Removed {dir_name} directory to clean build workspace." - ) + self.logger.info(f"Removed {dir_name} directory to clean build workspace.") def install_dependencies(self, context): """Install dependencies scoped to the workspace with best-effort retries.""" @@ -588,9 +569,7 @@ def install_dependencies(self, context): cwd=workspace_dir, ) except subprocess.CalledProcessError as exc: - self.logger.warning( - f"rosdep update failed ({exc}); continuing with cached data." - ) + self.logger.warning(f"rosdep update failed ({exc}); continuing with cached data.") try: subprocess.run( @@ -607,9 +586,7 @@ def install_dependencies(self, context): cwd=workspace_dir, ) except subprocess.CalledProcessError as exc: - self.logger.warning( - f"rosdep install encountered errors: {exc}." - ) + self.logger.warning(f"rosdep install encountered errors: {exc}.") def get_workspace_dir(self, context) -> str: """Get the workspace directory for the current stack.""" diff --git a/composer/stack_handlers/ditto_handler.py b/muto_composer/stack_handlers/ditto_handler.py similarity index 91% rename from composer/stack_handlers/ditto_handler.py rename to muto_composer/stack_handlers/ditto_handler.py index a217c79..6e22bda 100644 --- a/composer/stack_handlers/ditto_handler.py +++ b/muto_composer/stack_handlers/ditto_handler.py @@ -11,18 +11,24 @@ # Composiv.ai - initial API and implementation # -from typing import Dict, Any -from composer.plugins.base_plugin import BasePlugin, StackTypeHandler, StackContext, StackOperation -from composer.model.stack import Stack +from typing import Any + +from muto_composer.model.stack import Stack +from muto_composer.plugins.base_plugin import ( + BasePlugin, + StackContext, + StackOperation, + StackTypeHandler, +) class DittoStackHandler(StackTypeHandler): """Handler for Ditto (legacy launch/json) format stacks.""" - + def __init__(self, logger=None): self.logger = logger - - def can_handle(self, payload: Dict[str, Any]) -> bool: + + def can_handle(self, payload: dict[str, Any]) -> bool: """ Check if payload matches legacy format: - No proper metadata.content_type (not a properly defined solution) @@ -30,24 +36,23 @@ def can_handle(self, payload: Dict[str, Any]) -> bool: """ if not isinstance(payload, dict): return False - + metadata = payload.get("metadata", {}) content_type = metadata.get("content_type") - + # If there's a content_type, it's a properly defined solution if content_type and content_type not in ("stack/json", "stack/ditto"): return False - + # Check for legacy launch structures has_nodes = bool(payload.get("node") or payload.get("composable")) has_launch = bool(payload.get("launch")) has_legacy_patterns = bool( - payload.get("launch_description_source") or - (payload.get("on_start") and payload.get("on_kill")) + payload.get("launch_description_source") or (payload.get("on_start") and payload.get("on_kill")) ) - + return has_nodes or has_launch or has_legacy_patterns - + def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, response) -> bool: """Double dispatch: delegate to plugin's accept method.""" @@ -68,8 +73,7 @@ def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, re if self.logger: self.logger.error(f"Error processing Ditto stack operation: {e}") return False - - + def _start_ditto(self, context: StackContext) -> bool: """Start a Ditto stack.""" try: @@ -79,29 +83,29 @@ def _start_ditto(self, context: StackContext) -> bool: if self.logger: self.logger.info("Ditto script-based stack start delegated to plugin") return True - + # Otherwise, try to use Stack model with node/composable arrays if context.stack_data.get("node") or context.stack_data.get("composable"): stack = Stack(manifest=context.stack_data) stack.launch(context.launcher) return True - + # Check if there's a launch structure launch_data = context.stack_data.get("launch") if launch_data: stack = Stack(manifest=launch_data) stack.launch(context.launcher) return True - + if self.logger: self.logger.warning("No recognizable launch structure in Ditto stack") return False - + except Exception as e: if self.logger: self.logger.error(f"Error starting Ditto stack: {e}") return False - + def _kill_ditto(self, context: StackContext) -> bool: """Kill a Ditto stack.""" try: @@ -111,29 +115,29 @@ def _kill_ditto(self, context: StackContext) -> bool: if self.logger: self.logger.info("Ditto script-based stack kill delegated to plugin") return True - + # Otherwise, try to use Stack model if context.stack_data.get("node") or context.stack_data.get("composable"): stack = Stack(manifest=context.stack_data) stack.kill() return True - + # Check if there's a launch structure launch_data = context.stack_data.get("launch") if launch_data: stack = Stack(manifest=launch_data) stack.kill() return True - + if self.logger: self.logger.warning("No recognizable launch structure in Ditto stack for kill") return False - + except Exception as e: if self.logger: self.logger.error(f"Error killing Ditto stack: {e}") return False - + def _apply_ditto(self, context: StackContext) -> bool: """Apply a Ditto stack configuration.""" try: @@ -142,19 +146,19 @@ def _apply_ditto(self, context: StackContext) -> bool: stack = Stack(manifest=context.stack_data) stack.apply(context.launcher) return True - + # Check if there's a launch structure launch_data = context.stack_data.get("launch") if launch_data: stack = Stack(manifest=launch_data) stack.apply(context.launcher) return True - + # Script-based legacy stacks don't support apply - no-op if self.logger: self.logger.info("Ditto script-based stacks do not support apply (no-op)") return True - + except Exception as e: if self.logger: self.logger.error(f"Error applying Ditto stack: {e}") diff --git a/composer/stack_handlers/json_handler.py b/muto_composer/stack_handlers/json_handler.py similarity index 87% rename from composer/stack_handlers/json_handler.py rename to muto_composer/stack_handlers/json_handler.py index 808472a..6b54aee 100644 --- a/composer/stack_handlers/json_handler.py +++ b/muto_composer/stack_handlers/json_handler.py @@ -11,32 +11,37 @@ # Composiv.ai - initial API and implementation # -from typing import Dict, Any -from composer.plugins.base_plugin import StackTypeHandler, BasePlugin, StackContext, StackOperation -from composer.model.stack import Stack -from composer.workflow.launcher import Ros2LaunchParent +from typing import Any + +from muto_composer.model.stack import Stack +from muto_composer.plugins.base_plugin import ( + BasePlugin, + StackContext, + StackOperation, + StackTypeHandler, +) +from muto_composer.workflow.launcher import Ros2LaunchParent class JsonStackHandler(StackTypeHandler): """Handler for stack/json type stacks.""" - + def __init__(self, logger=None): self.logger = logger self.is_up_to_date = False self.managed_launchers = {} - - def can_handle(self, payload: Dict[str, Any]) -> bool: + def can_handle(self, payload: dict[str, Any]) -> bool: """Check for stack/json content_type in properly defined solution.""" if not isinstance(payload, dict): return False metadata = payload.get("metadata", {}) content_type = metadata.get("content_type", "") return content_type == "stack/json" - + def apply_to_plugin(self, plugin: BasePlugin, context: StackContext, request, response) -> bool: """Double dispatch: delegate to plugin's accept method.""" - + if context.operation == StackOperation.PROVISION: return self._provision_json(context, plugin) elif context.operation == StackOperation.START: @@ -62,13 +67,13 @@ def _provision_json(self, context: StackContext, plugin: BasePlugin) -> bool: return False def _start_json(self, context: StackContext, plugin: BasePlugin) -> bool: - + # JSON stacks support launch operations # For stack/json, the launch data is inside the manifest - + self._kill_json(context, plugin) - launcher = Ros2LaunchParent([]) + launcher = Ros2LaunchParent([]) launch_data = context.stack_data.get("launch") if not launch_data: self.logger.error("No 'launch' section found in stack/json manifest") @@ -77,16 +82,16 @@ def _start_json(self, context: StackContext, plugin: BasePlugin) -> bool: stack.launch(launcher) self.managed_launchers[context.hash] = launcher return True - + def _kill_json(self, context: StackContext, plugin: BasePlugin) -> bool: - + # JSON stacks support launch operations # For stack/json, the launch data is inside the manifest - #launch_data = context.stack_data.get("launch") - #if not launch_data: + # launch_data = context.stack_data.get("launch") + # if not launch_data: # self.logger.error("No 'launch' section found in stack/json manifest") # return False - #stack = Stack(manifest=launch_data) + # stack = Stack(manifest=launch_data) launcher = self.managed_launchers.get(context.hash, None) if launcher: launcher.kill() @@ -108,4 +113,3 @@ def _apply_json(self, context: StackContext, plugin: BasePlugin) -> bool: stack.apply(launcher) self.managed_launchers[context.hash] = launcher return True - diff --git a/composer/stack_handlers/registry.py b/muto_composer/stack_handlers/registry.py similarity index 82% rename from composer/stack_handlers/registry.py rename to muto_composer/stack_handlers/registry.py index fc4f024..a2fb1a9 100644 --- a/composer/stack_handlers/registry.py +++ b/muto_composer/stack_handlers/registry.py @@ -11,36 +11,32 @@ # Composiv.ai - initial API and implementation # -from typing import Dict, List, Optional from rclpy.node import Node -from composer.plugins.base_plugin import StackTypeHandler + +from muto_composer.plugins.base_plugin import StackTypeHandler class StackTypeRegistry: """Registry for stack type handlers.""" - + def __init__(self, node: Node, logger=None): - self.handlers: List[StackTypeHandler] = [] + self.handlers: list[StackTypeHandler] = [] if node: node.declare_parameter("ignored_packages", [""]) self.ignored_packages = [ - pkg - for pkg in node.get_parameter("ignored_packages") - .get_parameter_value() - .string_array_value - if pkg + pkg for pkg in node.get_parameter("ignored_packages").get_parameter_value().string_array_value if pkg ] self.logger = logger - + def register_handler(self, handler: StackTypeHandler) -> None: """Register a stack type handler.""" self.handlers.append(handler) - - def get_handler(self, payload: Dict) -> Optional[StackTypeHandler]: + + def get_handler(self, payload: dict) -> StackTypeHandler | None: """ Find the appropriate handler for a payload. - + Priority: 1. Properly defined solutions with metadata.content_type 2. Ditto format (legacy) validation @@ -48,30 +44,30 @@ def get_handler(self, payload: Dict) -> Optional[StackTypeHandler]: if not isinstance(payload, dict): self.logger.warning("Invalid payload type, expected dict") return None - + # Check if payload has proper metadata structure metadata = payload.get("metadata", {}) content_type = metadata.get("content_type") - + if not content_type: self.logger.debug("No content_type found, checking for Ditto format") - + # Try each registered handler for handler in self.handlers: if handler.can_handle(payload): handler_name = handler.__class__.__name__ self.logger.debug(f"Selected handler: {handler_name}") return handler - + self.logger.warning("No handler found for payload") return None - + def discover_and_register_handlers(self) -> None: """Automatically discover and register all available handlers.""" - from .json_handler import JsonStackHandler from .archive_handler import ArchiveStackHandler from .ditto_handler import DittoStackHandler - + from .json_handler import JsonStackHandler + # Register in priority order self.register_handler(JsonStackHandler(self.logger)) self.register_handler(ArchiveStackHandler(self.logger, self.ignored_packages)) diff --git a/composer/state/__init__.py b/muto_composer/state/__init__.py similarity index 87% rename from composer/state/__init__.py rename to muto_composer/state/__init__.py index 0a22cbe..f283596 100644 --- a/composer/state/__init__.py +++ b/muto_composer/state/__init__.py @@ -18,6 +18,6 @@ enabling rollback to previous versions on failure. """ -from composer.state.persistence import StatePersistence, StackState +from muto_composer.state.persistence import StackState, StatePersistence __all__ = ["StatePersistence", "StackState"] diff --git a/composer/state/persistence.py b/muto_composer/state/persistence.py similarity index 92% rename from composer/state/persistence.py rename to muto_composer/state/persistence.py index c9fbdd6..a84c0da 100644 --- a/composer/state/persistence.py +++ b/muto_composer/state/persistence.py @@ -18,19 +18,20 @@ rollback to previous versions when deployments fail. """ +import copy import json import os -import copy -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass from datetime import datetime -from typing import Dict, Any, Optional from enum import Enum +from typing import Any -from composer.utils.paths import get_state_path +from muto_composer.utils.paths import get_state_path class DeploymentStatus(Enum): """Deployment status enumeration.""" + PENDING = "pending" DEPLOYING = "deploying" RUNNING = "running" @@ -42,19 +43,20 @@ class DeploymentStatus(Enum): @dataclass class StackState: """Represents the persistent state of a stack deployment.""" + stack_id: str = "" stack_name: str = "" current_version: str = "" previous_version: str = "" - current_stack: Optional[Dict[str, Any]] = None - previous_stack: Optional[Dict[str, Any]] = None + current_stack: dict[str, Any] | None = None + previous_stack: dict[str, Any] | None = None status: str = DeploymentStatus.PENDING.value deployed_at: str = "" last_updated: str = "" error_message: str = "" rollback_count: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON serialization.""" return { "stack_id": self.stack_id, @@ -71,7 +73,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "StackState": + def from_dict(cls, data: dict[str, Any]) -> "StackState": """Create StackState from dictionary.""" return cls( stack_id=data.get("stack_id", ""), @@ -122,14 +124,14 @@ def _get_state_file_path(self, stack_name: str) -> str: """Get the state file path for a specific stack.""" return os.path.join(self._get_stack_state_dir(stack_name), self.STATE_FILENAME) - def _get_version_from_stack(self, stack: Optional[Dict[str, Any]]) -> str: + def _get_version_from_stack(self, stack: dict[str, Any] | None) -> str: """Extract version from stack metadata.""" if not stack: return "" metadata = stack.get("metadata", {}) return metadata.get("version", metadata.get("name", "unknown")) - def _get_stack_id_from_stack(self, stack: Optional[Dict[str, Any]]) -> str: + def _get_stack_id_from_stack(self, stack: dict[str, Any] | None) -> str: """Extract stack ID from stack.""" if not stack: return "" @@ -143,7 +145,7 @@ def _get_stack_id_from_stack(self, stack: Optional[Dict[str, Any]]) -> str: return metadata["name"] return stack.get("name", "") - def load_state(self, stack_name: str) -> Optional[StackState]: + def load_state(self, stack_name: str) -> StackState | None: """ Load the persisted state for a stack. @@ -161,7 +163,7 @@ def load_state(self, stack_name: str) -> Optional[StackState]: return None try: - with open(state_path, "r", encoding="utf-8") as f: + with open(state_path, encoding="utf-8") as f: data = json.load(f) state = StackState.from_dict(data) if self.logger: @@ -201,7 +203,7 @@ def save_state(self, stack_name: str, state: StackState) -> bool: self.logger.error(f"Failed to save state for {stack_name}: {e}") return False - def get_previous_stack(self, stack_name: str) -> Optional[Dict[str, Any]]: + def get_previous_stack(self, stack_name: str) -> dict[str, Any] | None: """ Get the previous stack definition for rollback. @@ -218,7 +220,7 @@ def get_previous_stack(self, stack_name: str) -> Optional[Dict[str, Any]]: return state.previous_stack return None - def mark_deployment_started(self, stack_name: str, next_stack: Dict[str, Any]) -> bool: + def mark_deployment_started(self, stack_name: str, next_stack: dict[str, Any]) -> bool: """ Mark deployment as started and save current stack as previous. @@ -335,10 +337,7 @@ def mark_rollback_completed(self, stack_name: str) -> bool: state.rollback_count += 1 if self.logger: - self.logger.info( - f"Rollback completed for {stack_name}: " - f"restored version {state.current_version}" - ) + self.logger.info(f"Rollback completed for {stack_name}: restored version {state.current_version}") return self.save_state(stack_name, state) @@ -355,7 +354,7 @@ def can_rollback(self, stack_name: str) -> bool: state = self.load_state(stack_name) return state is not None and state.previous_stack is not None - def get_all_stack_states(self) -> Dict[str, StackState]: + def get_all_stack_states(self) -> dict[str, StackState]: """ Get states for all tracked stacks. @@ -390,7 +389,7 @@ def _get_active_state_path(self) -> str: """Get the path to the global active deployment state file.""" return os.path.join(self._state_root, self.ACTIVE_STATE_DIR, self.STATE_FILENAME) - def load_active_state(self) -> Optional[StackState]: + def load_active_state(self) -> StackState | None: """ Load the global active deployment state. @@ -407,7 +406,7 @@ def load_active_state(self) -> Optional[StackState]: return None try: - with open(state_path, "r", encoding="utf-8") as f: + with open(state_path, encoding="utf-8") as f: data = json.load(f) state = StackState.from_dict(data) if self.logger: @@ -446,7 +445,7 @@ def save_active_state(self, state: StackState) -> bool: self.logger.error(f"Failed to save active state: {e}") return False - def mark_active_deployment_started(self, next_stack: Dict[str, Any]) -> bool: + def mark_active_deployment_started(self, next_stack: dict[str, Any]) -> bool: """ Mark a new deployment as started in the global active state. @@ -484,13 +483,12 @@ def mark_active_deployment_started(self, next_stack: Dict[str, Any]) -> bool: if self.logger: self.logger.info( - f"Active deployment started: {stack_name} v{state.current_version}, " - f"previous: {state.previous_version}" + f"Active deployment started: {stack_name} v{state.current_version}, previous: {state.previous_version}" ) return self.save_active_state(state) - def _get_stack_name_from_stack(self, stack: Optional[Dict[str, Any]]) -> Optional[str]: + def _get_stack_name_from_stack(self, stack: dict[str, Any] | None) -> str | None: """Extract stack name from stack definition.""" if not stack: return None @@ -540,7 +538,7 @@ def mark_active_deployment_failed(self, error: str = "") -> bool: return self.save_active_state(state) - def get_active_previous_stack(self) -> Optional[Dict[str, Any]]: + def get_active_previous_stack(self) -> dict[str, Any] | None: """ Get the previous stack from the global active state for rollback. @@ -603,8 +601,6 @@ def mark_active_rollback_completed(self) -> bool: state.rollback_count += 1 if self.logger: - self.logger.info( - f"Active rollback completed: restored {state.stack_name} v{state.current_version}" - ) + self.logger.info(f"Active rollback completed: restored {state.stack_name} v{state.current_version}") return self.save_active_state(state) diff --git a/composer/subsystems/__init__.py b/muto_composer/subsystems/__init__.py similarity index 99% rename from composer/subsystems/__init__.py rename to muto_composer/subsystems/__init__.py index 5a36059..c81d49c 100644 --- a/composer/subsystems/__init__.py +++ b/muto_composer/subsystems/__init__.py @@ -16,11 +16,11 @@ Contains modular components for stack management, orchestration, and pipeline execution. """ +from .digital_twin_integration import DigitalTwinIntegration from .message_handler import MessageHandler -from .stack_manager import StackManager from .orchestration_manager import OrchestrationManager from .pipeline_engine import PipelineEngine -from .digital_twin_integration import DigitalTwinIntegration +from .stack_manager import StackManager from .watchdog import ComposerWatchdog, HealthStatus, SubsystemHealth, SystemHealthReport __all__ = [ @@ -33,4 +33,4 @@ "HealthStatus", "SubsystemHealth", "SystemHealthReport", -] \ No newline at end of file +] diff --git a/composer/subsystems/digital_twin_integration.py b/muto_composer/subsystems/digital_twin_integration.py similarity index 83% rename from composer/subsystems/digital_twin_integration.py rename to muto_composer/subsystems/digital_twin_integration.py index 1911c91..af381cb 100644 --- a/composer/subsystems/digital_twin_integration.py +++ b/muto_composer/subsystems/digital_twin_integration.py @@ -16,42 +16,44 @@ Manages communication with CoreTwin services and digital twin synchronization. """ -import uuid -from typing import Dict, Any, Optional, List -from composer.events import ( - EventBus, EventType, StackAnalyzedEvent, StackRequestEvent, - OrchestrationStartedEvent -) +from typing import Any + import rclpy -from rclpy.node import Node -from rclpy.callback_groups import ReentrantCallbackGroup from muto_msgs.srv import CoreTwin +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.node import Node + +from muto_composer.events import ( + EventBus, + EventType, + OrchestrationStartedEvent, + StackAnalyzedEvent, + StackRequestEvent, +) class TwinServiceClient: """Manages communication with CoreTwin services.""" - + def __init__(self, node: Node, event_bus: EventBus, logger=None): self.node = node self.event_bus = event_bus self.logger = logger - + # Service clients for CoreTwin self.callback_group = ReentrantCallbackGroup() - + # Initialize service clients self.core_twin_client = self.node.create_client( - CoreTwin, - '/core_twin/get_stack_definition', - callback_group=self.callback_group + CoreTwin, "/core_twin/get_stack_definition", callback_group=self.callback_group ) - + # Subscribe to events that require twin services self.event_bus.subscribe(EventType.STACK_REQUEST, self.handle_stack_request) - + if self.logger: self.logger.info("TwinServiceClient initialized") - + def handle_stack_request(self, event: StackRequestEvent): """Handle stack request by fetching appropriate manifests.""" try: @@ -67,25 +69,25 @@ def handle_stack_request(self, event: StackRequestEvent): else: if self.logger: self.logger.warning(f"Unhandled action in stack request: {event.action}") - + except Exception as e: if self.logger: self.logger.error(f"Error handling stack request: {e}") - + def _handle_compose_request(self, event: StackRequestEvent): """Handle compose request by getting manifests.""" try: # Get real stack manifest first real_manifest = self.get_real_stack_manifest(event.stack_name) - + # Get desired stack manifest desired_manifest = self.get_desired_stack_manifest(event.stack_name) - + # If no desired manifest exists and we have a stack payload, create it if not desired_manifest and event.stack_payload: self.create_desired_stack_manifest(event.stack_name, event.stack_payload) desired_manifest = event.stack_payload - + # Publish stack analyzed event analyzed_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -97,40 +99,40 @@ def _handle_compose_request(self, event: StackRequestEvent): "stack_type": "compose", "requires_merging": bool(real_manifest), "has_desired_manifest": bool(desired_manifest), - "has_real_manifest": bool(real_manifest) + "has_real_manifest": bool(real_manifest), }, processing_requirements={ "merge_manifests": bool(real_manifest), "validate_dependencies": True, - "resolve_expressions": True + "resolve_expressions": True, }, stack_payload=event.stack_payload or {}, # Use direct field instead of nested structure metadata={ "desired_manifest": desired_manifest or {}, - "real_manifest": real_manifest or {} - } + "real_manifest": real_manifest or {}, + }, ) - + self.event_bus.publish_sync(analyzed_event) - + if self.logger: self.logger.info(f"Processed compose request for stack: {event.stack_name}") - + except Exception as e: if self.logger: self.logger.error(f"Error processing compose request: {e}") - + def _handle_decompose_request(self, event: StackRequestEvent): """Handle decompose request by getting current manifest.""" try: # Get current stack manifest current_manifest = self.get_desired_stack_manifest(event.stack_name) - + if not current_manifest: if self.logger: self.logger.warning(f"No manifest found for decompose: {event.stack_name}") return - + # Publish stack analyzed event analyzed_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -141,93 +143,93 @@ def _handle_decompose_request(self, event: StackRequestEvent): analysis_result={ "stack_type": "decompose", "requires_merging": False, - "has_current_manifest": True + "has_current_manifest": True, }, processing_requirements={ "merge_manifests": False, "validate_dependencies": False, - "resolve_expressions": False + "resolve_expressions": False, }, stack_payload=event.stack_payload or {}, # Use direct field instead of nested structure - metadata={ - "current_manifest": current_manifest - } + metadata={"current_manifest": current_manifest}, ) - + self.event_bus.publish_sync(analyzed_event) - + if self.logger: self.logger.info(f"Processed decompose request for stack: {event.stack_name}") - + except Exception as e: if self.logger: self.logger.error(f"Error processing decompose request: {e}") - - def get_desired_stack_manifest(self, stack_name: str) -> Optional[Dict[str, Any]]: + + def get_desired_stack_manifest(self, stack_name: str) -> dict[str, Any] | None: """Retrieve desired stack manifest from CoreTwin.""" try: if not self.core_twin_client.wait_for_service(timeout_sec=2.0): if self.logger: self.logger.warning("CoreTwin service not available") return None - + request = CoreTwin.Request() request.input = stack_name - + future = self.core_twin_client.call_async(request) rclpy.spin_until_future_complete(self.node, future, timeout_sec=5.0) - + if future.result(): response = future.result() if response.success: if self.logger: self.logger.debug(f"Retrieved desired manifest for stack: {stack_name}") import json + return json.loads(response.output) if response.output else {} else: if self.logger: self.logger.warning(f"Failed to get desired manifest: {response.message}") - + return None - + except Exception as e: if self.logger: self.logger.error(f"Error getting desired stack manifest: {e}") return None - - def get_real_stack_manifest(self, stack_name: str) -> Optional[Dict[str, Any]]: + + def get_real_stack_manifest(self, stack_name: str) -> dict[str, Any] | None: """Retrieve real stack manifest from CoreTwin.""" try: if not self.core_twin_client.wait_for_service(timeout_sec=2.0): if self.logger: self.logger.warning("CoreTwin service not available") return None - + request = CoreTwin.Request() request.input = f"real_{stack_name}" # Prefix to indicate real manifest - + future = self.core_twin_client.call_async(request) rclpy.spin_until_future_complete(self.node, future, timeout_sec=5.0) - + if future.result(): response = future.result() if response.success: if self.logger: self.logger.debug(f"Retrieved real manifest for stack: {stack_name}") import json + return json.loads(response.output) if response.output else {} else: if self.logger: self.logger.warning(f"Failed to get real manifest: {response.message}") - + return None - + except Exception as e: if self.logger: self.logger.error(f"Error getting real stack manifest: {e}") return None - - def create_desired_stack_manifest(self, stack_name: str, manifest_data: Dict[str, Any]) -> bool: + + def create_desired_stack_manifest(self, stack_name: str, manifest_data: dict[str, Any]) -> bool: """Create desired stack manifest in CoreTwin (stub implementation).""" try: # For now, this is a stub implementation since we don't have a separate service @@ -236,7 +238,7 @@ def create_desired_stack_manifest(self, stack_name: str, manifest_data: Dict[str self.logger.info(f"Would create desired manifest for stack: {stack_name}") self.logger.debug(f"Manifest data keys: {list(manifest_data.keys())}") return True - + except Exception as e: if self.logger: self.logger.error(f"Error creating desired stack manifest: {e}") @@ -245,56 +247,56 @@ def create_desired_stack_manifest(self, stack_name: str, manifest_data: Dict[str class TwinSynchronizer: """Manages digital twin synchronization and state consistency.""" - + def __init__(self, event_bus: EventBus, twin_client: TwinServiceClient, logger=None): self.event_bus = event_bus self.twin_client = twin_client self.logger = logger - + # Subscribe to events that require synchronization self.event_bus.subscribe(EventType.ORCHESTRATION_STARTED, self.handle_orchestration_started) - + # Track synchronization state - self.sync_state: Dict[str, Dict[str, Any]] = {} - + self.sync_state: dict[str, dict[str, Any]] = {} + if self.logger: self.logger.info("TwinSynchronizer initialized") - + def handle_orchestration_started(self, event: OrchestrationStartedEvent): """Handle orchestration start by ensuring twin synchronization.""" try: correlation_id = event.correlation_id stack_name = event.execution_plan.get("stack_name", "unknown") - + # Track synchronization for this orchestration self.sync_state[correlation_id] = { "stack_name": stack_name, "action": event.action, "status": "syncing", - "timestamp": event.timestamp + "timestamp": event.timestamp, } - + # Perform synchronization based on action if event.action in ["compose", "decompose"]: self._sync_for_stack_action(event) elif event.action == "kill": # Kill actions don't need twin synchronization if self.logger: - self.logger.debug(f"Kill action - skipping twin synchronization") + self.logger.debug("Kill action - skipping twin synchronization") self.sync_state[correlation_id]["status"] = "synchronized" else: if self.logger: self.logger.warning(f"No synchronization logic for action: {event.action}") - + except Exception as e: if self.logger: self.logger.error(f"Error handling orchestration started for sync: {e}") - + def _sync_for_stack_action(self, event: OrchestrationStartedEvent): """Synchronize twin state for stack actions.""" try: stack_name = event.execution_plan.get("stack_name", "unknown") - + if event.action == "compose": # Ensure desired manifest exists for compose desired_manifest = self.twin_client.get_desired_stack_manifest(stack_name) @@ -305,62 +307,61 @@ def _sync_for_stack_action(self, event: OrchestrationStartedEvent): self.twin_client.create_desired_stack_manifest(stack_name, stack_payload) if self.logger: self.logger.info(f"Created desired manifest during sync for: {stack_name}") - + elif event.action == "decompose": # Verify current state for decompose current_manifest = self.twin_client.get_desired_stack_manifest(stack_name) - if not current_manifest: - if self.logger: - self.logger.warning(f"No manifest to decompose for: {stack_name}") - + if not current_manifest and self.logger: + self.logger.warning(f"No manifest to decompose for: {stack_name}") + # Update sync state if event.correlation_id in self.sync_state: self.sync_state[event.correlation_id]["status"] = "synchronized" - + if self.logger: self.logger.debug(f"Twin synchronization completed for: {stack_name}") - + except Exception as e: if self.logger: self.logger.error(f"Error synchronizing twin state: {e}") - + # Update sync state with error if event.correlation_id in self.sync_state: self.sync_state[event.correlation_id]["status"] = "error" self.sync_state[event.correlation_id]["error"] = str(e) - - def get_sync_status(self, correlation_id: str) -> Optional[Dict[str, Any]]: + + def get_sync_status(self, correlation_id: str) -> dict[str, Any] | None: """Get synchronization status for a correlation ID.""" return self.sync_state.get(correlation_id) - + def cleanup_sync_state(self, correlation_id: str): """Clean up synchronization state for completed operations.""" if correlation_id in self.sync_state: del self.sync_state[correlation_id] if self.logger: self.logger.debug(f"Cleaned up sync state for: {correlation_id}") - + async def handle_stack_processed(self, event): """Handle stack processed events for twin synchronization.""" try: - if hasattr(event, 'stack_name') and hasattr(event, 'merged_stack'): + if hasattr(event, "stack_name") and hasattr(event, "merged_stack"): twin_data = self._extract_twin_data_from_stack(event.merged_stack) - twin_id = twin_data.get('twin_id', event.stack_name) + twin_id = twin_data.get("twin_id", event.stack_name) await self.sync_stack_state_to_twin(twin_id, twin_data) except Exception as e: if self.logger: self.logger.error(f"Error handling stack processed event: {e}") - + async def handle_deployment_status(self, event): """Handle deployment status events.""" try: - if hasattr(event, 'twin_id') and hasattr(event, 'data'): + if hasattr(event, "twin_id") and hasattr(event, "data"): await self.sync_stack_state_to_twin(event.twin_id, event.data) except Exception as e: if self.logger: self.logger.error(f"Error handling deployment status event: {e}") - - async def sync_stack_state_to_twin(self, twin_id: str, stack_data: Dict[str, Any]) -> bool: + + async def sync_stack_state_to_twin(self, twin_id: str, stack_data: dict[str, Any]) -> bool: """Synchronize stack state to digital twin.""" try: # This would call the twin service client to update the twin @@ -372,75 +373,75 @@ async def sync_stack_state_to_twin(self, twin_id: str, stack_data: Dict[str, Any if self.logger: self.logger.warning(f"Failed to sync stack state to twin {twin_id}: {e}") return False - - def _extract_twin_data_from_stack(self, stack_payload: Dict[str, Any]) -> Dict[str, Any]: + + def _extract_twin_data_from_stack(self, stack_payload: dict[str, Any]) -> dict[str, Any]: """Extract twin-relevant data from stack payload.""" twin_data = {} - - if 'metadata' in stack_payload: - metadata = stack_payload['metadata'] - twin_data['stack_name'] = metadata.get('name', 'unknown') - twin_data['twin_id'] = metadata.get('twin_id', twin_data['stack_name']) - - if 'nodes' in stack_payload: - twin_data['nodes'] = stack_payload['nodes'] - + + if "metadata" in stack_payload: + metadata = stack_payload["metadata"] + twin_data["stack_name"] = metadata.get("name", "unknown") + twin_data["twin_id"] = metadata.get("twin_id", twin_data["stack_name"]) + + if "nodes" in stack_payload: + twin_data["nodes"] = stack_payload["nodes"] + return twin_data class DigitalTwinIntegration: """Main digital twin integration subsystem coordinator.""" - + def __init__(self, node: Node, event_bus: EventBus, logger=None): self.node = node self.event_bus = event_bus self.logger = logger - + # Initialize components self.twin_client = TwinServiceClient(node, event_bus, logger) self.synchronizer = TwinSynchronizer(event_bus, self.twin_client, logger) - + if self.logger: self.logger.info("DigitalTwinIntegration subsystem initialized") - + def get_twin_client(self) -> TwinServiceClient: """Get twin service client.""" return self.twin_client - + def get_synchronizer(self) -> TwinSynchronizer: """Get twin synchronizer.""" return self.synchronizer - + # Legacy interface methods for compatibility - def get_desired_stack_manifest(self, stack_name: str) -> Optional[Dict[str, Any]]: + def get_desired_stack_manifest(self, stack_name: str) -> dict[str, Any] | None: """Legacy interface: Get desired stack manifest.""" return self.twin_client.get_desired_stack_manifest(stack_name) - - def get_real_stack_manifest(self, stack_name: str) -> Optional[Dict[str, Any]]: + + def get_real_stack_manifest(self, stack_name: str) -> dict[str, Any] | None: """Legacy interface: Get real stack manifest.""" return self.twin_client.get_real_stack_manifest(stack_name) - - def create_desired_stack_manifest(self, stack_name: str, manifest_data: Dict[str, Any]) -> bool: + + def create_desired_stack_manifest(self, stack_name: str, manifest_data: dict[str, Any]) -> bool: """Legacy interface: Create desired stack manifest.""" return self.twin_client.create_desired_stack_manifest(stack_name, manifest_data) - + def enable(self): """Enable digital twin integration.""" if self.logger: self.logger.info("Digital twin integration enabled") - + def disable(self): """Disable digital twin integration.""" if self.logger: self.logger.info("Digital twin integration disabled") - - def _extract_twin_id(self, stack_payload: Dict[str, Any]) -> str: + + def _extract_twin_id(self, stack_payload: dict[str, Any]) -> str: """Extract twin ID from stack payload.""" - if 'metadata' in stack_payload: - metadata = stack_payload['metadata'] - if 'twin_id' in metadata: - return metadata['twin_id'] - elif 'name' in metadata: - return metadata['name'] - - return "unknown_twin" \ No newline at end of file + if "metadata" in stack_payload: + metadata = stack_payload["metadata"] + if "twin_id" in metadata: + return metadata["twin_id"] + elif "name" in metadata: + return metadata["name"] + + return "unknown_twin" diff --git a/composer/subsystems/message_handler.py b/muto_composer/subsystems/message_handler.py similarity index 80% rename from composer/subsystems/message_handler.py rename to muto_composer/subsystems/message_handler.py index 29ff557..49dc794 100644 --- a/composer/subsystems/message_handler.py +++ b/muto_composer/subsystems/message_handler.py @@ -17,142 +17,138 @@ """ import json -from typing import Dict, Any, Optional -from rclpy.node import Node -from std_msgs.msg import String +from typing import Any + from muto_msgs.msg import MutoAction from muto_msgs.srv import CoreTwin -from composer.events import EventBus, StackRequestEvent, EventType +from rclpy.node import Node +from std_msgs.msg import String + +from muto_composer.events import EventBus, EventType, StackRequestEvent class MessageRouter: """Routes incoming messages to appropriate handlers via events.""" - + def __init__(self, event_bus: EventBus, logger=None): self.event_bus = event_bus self.logger = logger - + def route_muto_action(self, action: MutoAction) -> None: """Route MutoAction to orchestration manager via events.""" try: payload = json.loads(action.payload) stack_name = self._extract_stack_name(payload, f"unknown:{action.method}") - + event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="message_router", stack_name=stack_name, action=action.method, - stack_payload=payload + stack_payload=payload, ) - + if self.logger: self.logger.info(f"Routing {action.method} action via event system") - + self.event_bus.publish_sync(event) - + except json.JSONDecodeError as e: if self.logger: self.logger.error(f"Failed to parse MutoAction payload: {e}") except Exception as e: if self.logger: self.logger.error(f"Error routing MutoAction: {e}") - - def _extract_stack_name(self, payload: Dict[str, Any], default_name: str) -> str: + + def _extract_stack_name(self, payload: dict[str, Any], default_name: str) -> str: """Extract stack name from payload.""" # Try to extract from value key - if 'value' in payload and 'stackId' in payload['value']: - return payload['value']['stackId'] - + if "value" in payload and "stackId" in payload["value"]: + return payload["value"]["stackId"] + # Try to extract from metadata - if 'metadata' in payload and 'name' in payload['metadata']: - return payload['metadata']['name'] - + if "metadata" in payload and "name" in payload["metadata"]: + return payload["metadata"]["name"] + # Return default if not found return default_name class PublisherManager: """Manages all outbound publishing with consolidated publishers.""" - + def __init__(self, node: Node): self.node = node # Consolidated publisher instead of multiple deprecated ones self.stack_state_pub = node.create_publisher(String, "stack_state", 10) self.logger = node.get_logger() - - def publish_stack_state(self, stack_data: Dict[str, Any], state_type: str = "current") -> None: + + def publish_stack_state(self, stack_data: dict[str, Any], state_type: str = "current") -> None: """Publish consolidated stack state information.""" try: # Create consolidated state message state_message = { "type": state_type, "timestamp": str(self.node.get_clock().now().to_msg()), - "data": stack_data + "data": stack_data, } - + msg = String() msg.data = json.dumps(state_message) self.stack_state_pub.publish(msg) - + self.logger.debug(f"Published {state_type} stack state") - + except Exception as e: self.logger.error(f"Error publishing stack state: {e}") class ServiceClientManager: """Manages service client connections and calls.""" - + def __init__(self, node: Node, core_twin_node_name: str = "core_twin"): self.node = node self.logger = node.get_logger() - + # Initialize service clients - self.get_stack_client = node.create_client( - CoreTwin, - f"{core_twin_node_name}/get_stack_definition" - ) - self.set_stack_client = node.create_client( - CoreTwin, - f"{core_twin_node_name}/set_current_stack" - ) - - async def get_stack_definition(self, stack_id: str) -> Optional[Dict[str, Any]]: + self.get_stack_client = node.create_client(CoreTwin, f"{core_twin_node_name}/get_stack_definition") + self.set_stack_client = node.create_client(CoreTwin, f"{core_twin_node_name}/set_current_stack") + + async def get_stack_definition(self, stack_id: str) -> dict[str, Any] | None: """Retrieve stack definition from twin service.""" try: request = CoreTwin.Request() request.input = stack_id - + if not self.get_stack_client.wait_for_service(timeout_sec=5.0): self.logger.error("CoreTwin get_stack_definition service not available") return None - - future = self.get_stack_client.call_async(request) + + self.get_stack_client.call_async(request) # Note: In real implementation, this would be properly awaited # For now, we'll use the existing callback pattern - + return {} # Placeholder - + except Exception as e: self.logger.error(f"Error calling get_stack_definition: {e}") return None - + async def set_current_stack(self, stack_id: str) -> bool: """Update current stack in twin service.""" try: request = CoreTwin.Request() request.input = stack_id - + if not self.set_stack_client.wait_for_service(timeout_sec=5.0): self.logger.error("CoreTwin set_current_stack service not available") return False - - future = self.set_stack_client.call_async(request) + + self.set_stack_client.call_async(request) # Note: In real implementation, this would be properly awaited - + return True # Placeholder - + except Exception as e: self.logger.error(f"Error calling set_current_stack: {e}") return False @@ -160,39 +156,34 @@ async def set_current_stack(self, stack_id: str) -> bool: class MessageHandler: """Main message handling subsystem coordinator.""" - + def __init__(self, node: Node, event_bus: EventBus, core_twin_node_name: str = "core_twin"): self.node = node self.event_bus = event_bus self.logger = node.get_logger() - + # Initialize components self.router = MessageRouter(event_bus, self.logger) self.publisher_manager = PublisherManager(node) self.service_manager = ServiceClientManager(node, core_twin_node_name) # Add alias for compatibility self.service_client_manager = self.service_manager - + # Set up subscribers self._setup_subscribers() - + self.logger.info("MessageHandler subsystem initialized") - + def _setup_subscribers(self): """Set up ROS 2 subscribers.""" # Get stack topic from parameters stack_topic = self.node.get_parameter("stack_topic").get_parameter_value().string_value - + # Subscribe to MutoAction messages - self.node.create_subscription( - MutoAction, - stack_topic, - self._muto_action_callback, - 10 - ) - + self.node.create_subscription(MutoAction, stack_topic, self._muto_action_callback, 10) + self.logger.info(f"Subscribed to {stack_topic} for MutoAction messages") - + def _muto_action_callback(self, msg: MutoAction): """Callback for MutoAction messages.""" try: @@ -200,27 +191,27 @@ def _muto_action_callback(self, msg: MutoAction): self.router.route_muto_action(msg) except Exception as e: self.logger.error(f"Error in MutoAction callback: {e}") - - def publish_stack_state(self, stack_data: Dict[str, Any], state_type: str = "current"): + + def publish_stack_state(self, stack_data: dict[str, Any], state_type: str = "current"): """Publish stack state through publisher manager.""" self.publisher_manager.publish_stack_state(stack_data, state_type) - + def get_service_manager(self) -> ServiceClientManager: """Get service client manager for external use.""" return self.service_manager - + def handle_muto_action(self, muto_action: MutoAction): """Handle MutoAction message.""" self.router.route_muto_action(muto_action) - + def get_router(self) -> MessageRouter: """Get message router.""" return self.router - + def get_publisher_manager(self) -> PublisherManager: """Get publisher manager.""" return self.publisher_manager - + def get_service_client_manager(self) -> ServiceClientManager: """Get service client manager (alias for compatibility).""" - return self.service_manager \ No newline at end of file + return self.service_manager diff --git a/composer/subsystems/orchestration_manager.py b/muto_composer/subsystems/orchestration_manager.py similarity index 86% rename from composer/subsystems/orchestration_manager.py rename to muto_composer/subsystems/orchestration_manager.py index c7fc143..ef3112e 100644 --- a/composer/subsystems/orchestration_manager.py +++ b/muto_composer/subsystems/orchestration_manager.py @@ -17,44 +17,56 @@ """ import uuid -from typing import Dict, Any, Optional -from composer.subsystems.stack_manager import StackType from dataclasses import dataclass -from composer.events import ( - EventBus, EventType, StackAnalyzedEvent, OrchestrationStartedEvent, - OrchestrationCompletedEvent, OrchestrationFailedEvent, PipelineRequestedEvent, - PipelineCompletedEvent, PipelineFailedEvent, RollbackStartedEvent, RollbackCompletedEvent, - RollbackFailedEvent, ProcessCrashedEvent +from typing import Any + +from muto_composer.events import ( + EventBus, + EventType, + OrchestrationCompletedEvent, + OrchestrationFailedEvent, + OrchestrationStartedEvent, + PipelineCompletedEvent, + PipelineFailedEvent, + ProcessCrashedEvent, + RollbackCompletedEvent, + RollbackFailedEvent, + RollbackStartedEvent, + StackAnalyzedEvent, ) -from composer.state.persistence import StatePersistence +from muto_composer.state.persistence import StatePersistence +from muto_composer.subsystems.stack_manager import StackType @dataclass class ExecutionPath: """Represents an execution path for stack deployment.""" + pipeline_name: str - context_variables: Dict[str, Any] + context_variables: dict[str, Any] requires_merging: bool = False - - def to_dict(self) -> Dict[str, Any]: + + def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "pipeline_name": self.pipeline_name, "context_variables": self.context_variables, - "requires_merging": self.requires_merging + "requires_merging": self.requires_merging, } class ExecutionPathDeterminer: """Determines execution path based on stack analysis.""" - + def __init__(self, logger=None): self.logger = logger - - def determine_path(self, - analyzed_event: StackAnalyzedEvent, - current_stack: Optional[Dict] = None, - next_stack: Optional[Dict] = None) -> ExecutionPath: + + def determine_path( + self, + analyzed_event: StackAnalyzedEvent, + current_stack: dict | None = None, + next_stack: dict | None = None, + ) -> ExecutionPath: """Determine execution path and context variables.""" try: @@ -67,26 +79,22 @@ def determine_path(self, # Handle kill action specially - it just needs to terminate processes if action == "kill" or analysis_result.get("is_kill_action"): if self.logger: - self.logger.info(f"Kill action detected, executing kill pipeline") + self.logger.info("Kill action detected, executing kill pipeline") return ExecutionPath( pipeline_name="kill", context_variables={ "should_run_provision": False, "should_run_launch": True, # LaunchPlugin handles kills "is_kill_action": True, - "stack_id": analysis_result.get("stack_id") + "stack_id": analysis_result.get("stack_id"), }, - requires_merging=False + requires_merging=False, ) # Complex logic extracted from original determine_execution_path method - is_next_stack_empty = (not stack_payload.get("node", "") and - not stack_payload.get("composable", "")) + is_next_stack_empty = not stack_payload.get("node", "") and not stack_payload.get("composable", "") has_launch_description = bool(stack_payload.get("launch_description_source")) - has_on_start_and_on_kill = all([ - stack_payload.get("on_start"), - stack_payload.get("on_kill") - ]) + has_on_start_and_on_kill = all([stack_payload.get("on_start"), stack_payload.get("on_kill")]) # Determine execution requirements based on stack type and characteristics if stack_type == StackType.ARCHIVE.value: @@ -95,55 +103,55 @@ def determine_path(self, requires_merging = False if self.logger: self.logger.info("Archive manifest detected; running ProvisionPlugin and LaunchPlugin") - + elif stack_type == StackType.JSON.value: should_run_provision = False should_run_launch = True requires_merging = True if self.logger: self.logger.info("JSON manifest detected; running LaunchPlugin") - + elif is_next_stack_empty and (has_launch_description or has_on_start_and_on_kill): should_run_provision = False should_run_launch = True requires_merging = False if self.logger: self.logger.info("Legacy stack conditions met to run LaunchPlugin") - + elif not is_next_stack_empty: should_run_provision = False should_run_launch = True requires_merging = True if self.logger: self.logger.info("Conditions met to merge stacks and bypass ProvisionPlugin") - + else: should_run_provision = False should_run_launch = False requires_merging = False if self.logger: self.logger.info("Conditions not met to run ProvisionPlugin AND LaunchPlugin") - + context_variables = { "should_run_provision": should_run_provision, "should_run_launch": should_run_launch, } - + return ExecutionPath( pipeline_name=action, context_variables=context_variables, - requires_merging=requires_merging + requires_merging=requires_merging, ) - + except Exception as e: if self.logger: self.logger.error(f"Error determining execution path: {e}") - + # Fallback path return ExecutionPath( pipeline_name=analyzed_event.action, context_variables={"should_run_provision": False, "should_run_launch": False}, - requires_merging=False + requires_merging=False, ) @@ -164,13 +172,13 @@ def __init__(self, event_bus: EventBus, logger=None): self.event_bus.subscribe(EventType.PROCESS_CRASHED, self.handle_process_crashed) # Keep track of active orchestrations - self.active_orchestrations: Dict[str, Dict[str, Any]] = {} + self.active_orchestrations: dict[str, dict[str, Any]] = {} # Track if we're currently in a rollback to prevent rollback loops self._rollback_in_progress: bool = False if self.logger: self.logger.info("DeploymentOrchestrator initialized with rollback support") - + def handle_stack_analyzed(self, event: StackAnalyzedEvent): """Handle analyzed stack by determining orchestration path.""" try: @@ -186,14 +194,14 @@ def handle_stack_analyzed(self, event: StackAnalyzedEvent): if self.logger: stack_name = self._get_stack_name_from_payload(stack_payload) self.logger.info(f"Saved active state for rollback: {stack_name}") - + # Store orchestration context self.active_orchestrations[orchestration_id] = { "event": event, "execution_path": execution_path, - "status": "started" + "status": "started", } - + orchestration_event = OrchestrationStartedEvent( event_type=EventType.ORCHESTRATION_STARTED, source_component="deployment_orchestrator", @@ -203,27 +211,25 @@ def handle_stack_analyzed(self, event: StackAnalyzedEvent): execution_plan=execution_path.to_dict(), context_variables=execution_path.context_variables, stack_payload=event.stack_payload, # Pass stack_payload directly from analyzed event - metadata={ - "requires_merging": execution_path.requires_merging - } + metadata={"requires_merging": execution_path.requires_merging}, ) - + if self.logger: self.logger.info(f"Starting orchestration {orchestration_id} for {event.metadata.get('action')}") - + self.event_bus.publish_sync(orchestration_event) - + except Exception as e: if self.logger: self.logger.error(f"Error handling stack analyzed event: {e}") - + def handle_stack_merged(self, event): """Handle stack merged event - may trigger pipeline execution.""" # This could be used to continue orchestration after stack merging if self.logger: self.logger.debug("Stack merged event received in orchestrator") - def _get_stack_name_from_payload(self, stack_payload: Dict[str, Any]) -> Optional[str]: + def _get_stack_name_from_payload(self, stack_payload: dict[str, Any]) -> str | None: """Extract stack name from stack payload.""" if not stack_payload: return None @@ -272,7 +278,7 @@ def handle_pipeline_completed(self, event: PipelineCompletedEvent): source_component="deployment_orchestrator", orchestration_id=orchestration_id, restored_stack=stack_payload or {}, - rollback_duration=0.0 + rollback_duration=0.0, ) self.event_bus.publish_sync(rollback_completed) else: @@ -288,7 +294,7 @@ def handle_pipeline_completed(self, event: PipelineCompletedEvent): if self.logger: self.logger.error(f"Error handling pipeline completion: {e}") - def complete_orchestration(self, orchestration_id: str, final_stack_state: Dict[str, Any]): + def complete_orchestration(self, orchestration_id: str, final_stack_state: dict[str, Any]): """Complete an orchestration.""" try: if orchestration_id in self.active_orchestrations: @@ -301,7 +307,7 @@ def complete_orchestration(self, orchestration_id: str, final_stack_state: Dict[ orchestration_id=orchestration_id, final_stack_state=final_stack_state, execution_summary={"status": "success"}, - duration=0.0 # Would be calculated in real implementation + duration=0.0, # Would be calculated in real implementation ) self.event_bus.publish_sync(completion_event) @@ -328,25 +334,21 @@ def handle_pipeline_failed(self, event: PipelineFailedEvent): # Don't trigger rollback if we're already in a rollback if self._rollback_in_progress: if self.logger: - self.logger.error( - f"Rollback failed: {event.pipeline_name} - {event.error_details}" - ) + self.logger.error(f"Rollback failed: {event.pipeline_name} - {event.error_details}") # Publish rollback failed event rollback_failed = RollbackFailedEvent( event_type=EventType.ROLLBACK_FAILED, source_component="deployment_orchestrator", orchestration_id=event.execution_id, error_details=str(event.error_details), - original_failure="Rollback pipeline failed" + original_failure="Rollback pipeline failed", ) self.event_bus.publish_sync(rollback_failed) self._rollback_in_progress = False return if self.logger: - self.logger.error( - f"Pipeline failed: {event.pipeline_name} at step {event.failure_step}" - ) + self.logger.error(f"Pipeline failed: {event.pipeline_name} at step {event.failure_step}") # Mark deployment as failed in active state self.state_persistence.mark_active_deployment_failed(str(event.error_details)) @@ -371,7 +373,7 @@ def handle_pipeline_failed(self, event: PipelineFailedEvent): orchestration_id=event.execution_id, error_details=str(event.error_details), failed_step=event.failure_step, - can_rollback=False + can_rollback=False, ) self.event_bus.publish_sync(failed_event) @@ -385,16 +387,13 @@ def handle_process_crashed(self, event: ProcessCrashedEvent): # Don't trigger rollback if we're already in a rollback if self._rollback_in_progress: if self.logger: - self.logger.error( - f"Process crashed during rollback: {event.process_name} - {event.error_message}" - ) + self.logger.error(f"Process crashed during rollback: {event.process_name} - {event.error_message}") self._rollback_in_progress = False return if self.logger: self.logger.error( - f"Process crashed: {event.process_name} (stack: {event.stack_name}, " - f"exit code: {event.exit_code})" + f"Process crashed: {event.process_name} (stack: {event.stack_name}, exit code: {event.exit_code})" ) # Mark deployment as failed in active state @@ -417,10 +416,10 @@ def handle_process_crashed(self, event: ProcessCrashedEvent): if self.logger: self.logger.error(f"Error handling process crash: {e}") - def _get_stack_name_from_context(self, event: PipelineFailedEvent) -> Optional[str]: + def _get_stack_name_from_context(self, event: PipelineFailedEvent) -> str | None: """Extract stack name from pipeline failure event context.""" # Try to find from active orchestrations - for orch_id, context in self.active_orchestrations.items(): + for _orch_id, context in self.active_orchestrations.items(): if hasattr(context.get("event"), "stack_name"): return context["event"].stack_name # Try to get from stack_payload @@ -432,7 +431,7 @@ def _get_stack_name_from_context(self, event: PipelineFailedEvent) -> Optional[s return name return None - def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], failure_reason: str): + def trigger_rollback(self, stack_name: str, previous_stack: dict[str, Any], failure_reason: str): """Trigger rollback to previous stack version.""" try: if self._rollback_in_progress: @@ -455,7 +454,7 @@ def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], fail source_component="deployment_orchestrator", previous_stack=previous_stack, failed_stack=failed_stack, - failure_reason=failure_reason + failure_reason=failure_reason, ) self.event_bus.publish_sync(rollback_event) @@ -468,9 +467,9 @@ def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], fail context_variables={ "should_run_provision": True, # May need to provision previous version "should_run_launch": True, - "is_rollback": True + "is_rollback": True, }, - requires_merging=False + requires_merging=False, ) # Store rollback orchestration context @@ -478,7 +477,7 @@ def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], fail "execution_path": execution_path, "status": "rollback_started", "previous_stack": previous_stack, - "failed_stack": failed_stack + "failed_stack": failed_stack, } # Trigger orchestration with previous stack @@ -490,7 +489,7 @@ def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], fail execution_plan=execution_path.to_dict(), context_variables=execution_path.context_variables, stack_payload=previous_stack, - metadata={"is_rollback": True, "failure_reason": failure_reason} + metadata={"is_rollback": True, "failure_reason": failure_reason}, ) self.event_bus.publish_sync(orchestration_event) @@ -506,17 +505,17 @@ def trigger_rollback(self, stack_name: str, previous_stack: Dict[str, Any], fail class OrchestrationManager: """Main orchestration management subsystem coordinator.""" - + def __init__(self, event_bus: EventBus, logger=None): self.event_bus = event_bus self.logger = logger - + # Initialize components self.orchestrator = DeploymentOrchestrator(event_bus, logger) - + if self.logger: self.logger.info("OrchestrationManager subsystem initialized") - + def get_orchestrator(self) -> DeploymentOrchestrator: """Get deployment orchestrator.""" - return self.orchestrator \ No newline at end of file + return self.orchestrator diff --git a/composer/subsystems/pipeline_engine.py b/muto_composer/subsystems/pipeline_engine.py similarity index 72% rename from composer/subsystems/pipeline_engine.py rename to muto_composer/subsystems/pipeline_engine.py index 6637bd4..6ae57d6 100644 --- a/composer/subsystems/pipeline_engine.py +++ b/muto_composer/subsystems/pipeline_engine.py @@ -17,38 +17,43 @@ """ import os -import yaml import uuid -from typing import Dict, Any, Optional +from typing import Any + +import yaml from ament_index_python.packages import get_package_share_directory -from jsonschema import validate, ValidationError -from composer.workflow.pipeline import Pipeline -from composer.workflow.schemas.pipeline_schema import PIPELINE_SCHEMA -from composer.events import ( - EventBus, EventType, OrchestrationStartedEvent, PipelineRequestedEvent, - PipelineStartedEvent, PipelineCompletedEvent, PipelineFailedEvent +from jsonschema import ValidationError, validate + +from muto_composer.events import ( + EventBus, + EventType, + OrchestrationStartedEvent, + PipelineCompletedEvent, + PipelineFailedEvent, + PipelineRequestedEvent, + PipelineStartedEvent, ) +from muto_composer.workflow.pipeline import Pipeline +from muto_composer.workflow.schemas.pipeline_schema import PIPELINE_SCHEMA class PipelineManager: """Manages pipeline configurations and lifecycle.""" - - def __init__(self, config_path: Optional[str] = None, logger=None): + + def __init__(self, config_path: str | None = None, logger=None): self.logger = logger - self.pipelines: Dict[str, Pipeline] = {} - + self.pipelines: dict[str, Pipeline] = {} + # Set default config path if not provided if not config_path: - config_path = os.path.join( - get_package_share_directory("composer"), "config", "pipeline.yaml" - ) - + config_path = os.path.join(get_package_share_directory("muto_composer"), "config", "pipeline.yaml") + self.config_path = config_path self._load_and_initialize_pipelines() - + if self.logger: self.logger.info(f"PipelineManager initialized with {len(self.pipelines)} pipelines") - + def _load_and_initialize_pipelines(self): """Load and initialize all configured pipelines.""" try: @@ -58,20 +63,20 @@ def _load_and_initialize_pipelines(self): if self.logger: self.logger.error(f"Failed to initialize pipelines: {e}") raise - - def load_pipeline_config(self, config_path: str) -> Dict[str, Any]: + + def load_pipeline_config(self, config_path: str) -> dict[str, Any]: """Load and validate pipeline configuration.""" try: - with open(config_path, "r") as f: + with open(config_path) as f: config = yaml.safe_load(f) - + validate(instance=config, schema=PIPELINE_SCHEMA) - + if self.logger: self.logger.info(f"Loaded pipeline configuration from {config_path}") - + return config - + except FileNotFoundError: if self.logger: self.logger.error(f"Pipeline configuration file not found: {config_path}") @@ -79,49 +84,49 @@ def load_pipeline_config(self, config_path: str) -> Dict[str, Any]: except ValidationError as e: if self.logger: self.logger.error(f"Invalid pipeline configuration: {e}") - raise ValueError(f"Invalid pipeline configuration: {e}") + raise ValueError(f"Invalid pipeline configuration: {e}") from e except Exception as e: if self.logger: self.logger.error(f"Error loading pipeline configuration: {e}") raise - - def initialize_pipelines(self, config: Dict[str, Any]): + + def initialize_pipelines(self, config: dict[str, Any]): """Initialize all configured pipelines.""" try: loaded_pipelines = {} - + for pipeline_item in config.get("pipelines", []): name = pipeline_item["name"] pipeline_spec = pipeline_item["pipeline"] compensation_spec = pipeline_item.get("compensation", None) - + pipeline = Pipeline(name, pipeline_spec, compensation_spec) loaded_pipelines[name] = pipeline - + if self.logger: self.logger.debug(f"Initialized pipeline: {name}") - + self.pipelines = loaded_pipelines - + if self.logger: self.logger.info(f"Successfully initialized {len(loaded_pipelines)} pipelines") - + except Exception as e: if self.logger: self.logger.error(f"Error initializing pipelines: {e}") raise - - def get_pipeline(self, name: str) -> Optional[Pipeline]: + + def get_pipeline(self, name: str) -> Pipeline | None: """Retrieve pipeline by name.""" pipeline = self.pipelines.get(name) if not pipeline and self.logger: self.logger.warning(f"Pipeline '{name}' not found") return pipeline - - def get_available_pipelines(self) -> Dict[str, Pipeline]: + + def get_available_pipelines(self) -> dict[str, Pipeline]: """Get all available pipelines.""" return self.pipelines.copy() - + def reload_configuration(self): """Reload pipeline configuration from file.""" try: @@ -136,156 +141,164 @@ def reload_configuration(self): class PipelineExecutor: """Executes pipelines with context and error handling.""" - + def __init__(self, event_bus: EventBus, pipeline_manager: PipelineManager, logger=None): self.event_bus = event_bus self.pipeline_manager = pipeline_manager self.logger = logger - + # Subscribe to orchestration events self.event_bus.subscribe(EventType.ORCHESTRATION_STARTED, self.handle_orchestration_started) - + # Track active executions - self.active_executions: Dict[str, Dict[str, Any]] = {} - + self.active_executions: dict[str, dict[str, Any]] = {} + if self.logger: self.logger.info("PipelineExecutor initialized") - + def handle_orchestration_started(self, event: OrchestrationStartedEvent): """Handle orchestration start by executing appropriate pipeline.""" try: pipeline_name = event.execution_plan.get("pipeline_name", event.action) context = event.context_variables - + # Check if stack merging is required first requires_merging = event.metadata.get("requires_merging", False) stack_payload = event.stack_payload # Use direct field instead of metadata - - if requires_merging: + + if requires_merging and self.logger: # For now, we'll proceed directly to pipeline execution # In a full implementation, this would wait for stack merging to complete - if self.logger: - self.logger.info("Stack merging required, proceeding with pipeline execution") - + self.logger.info("Stack merging required, proceeding with pipeline execution") + pipeline_event = PipelineRequestedEvent( event_type=EventType.PIPELINE_REQUESTED, source_component="pipeline_executor", correlation_id=event.correlation_id, pipeline_name=pipeline_name, execution_context=context, - stack_payload=stack_payload # Use consistent naming + stack_payload=stack_payload, # Use consistent naming ) - + if self.logger: self.logger.info(f"Requesting pipeline execution: {pipeline_name}") - + self.event_bus.publish_sync(pipeline_event) self._execute_pipeline_internal(pipeline_event) - + except Exception as e: if self.logger: self.logger.error(f"Error handling orchestration started: {e}") - + def _execute_pipeline_internal(self, event: PipelineRequestedEvent): """Internal pipeline execution logic.""" execution_id = str(uuid.uuid4()) - + try: pipeline = self.pipeline_manager.get_pipeline(event.pipeline_name) if not pipeline: - self._publish_pipeline_failed(event, execution_id, "pipeline_lookup", - f"No pipeline found: {event.pipeline_name}") + self._publish_pipeline_failed( + event, + execution_id, + "pipeline_lookup", + f"No pipeline found: {event.pipeline_name}", + ) return - + # Store execution context self.active_executions[execution_id] = { "event": event, "pipeline": pipeline, - "status": "running" + "status": "running", } - + # Publish pipeline started event - self.event_bus.publish_sync(PipelineStartedEvent( - event_type=EventType.PIPELINE_STARTED, - source_component="pipeline_executor", - correlation_id=event.correlation_id, - pipeline_name=event.pipeline_name, - execution_id=execution_id, - steps_planned=self._extract_step_names(pipeline) - )) - + self.event_bus.publish_sync( + PipelineStartedEvent( + event_type=EventType.PIPELINE_STARTED, + source_component="pipeline_executor", + correlation_id=event.correlation_id, + pipeline_name=event.pipeline_name, + execution_id=execution_id, + steps_planned=self._extract_step_names(pipeline), + ) + ) + if self.logger: self.logger.info(f"Starting pipeline execution: {event.pipeline_name} [{execution_id}]") - + # Execute pipeline result = self._execute_pipeline_real(pipeline, event) - + # Check if pipeline execution was successful if result.get("success", False): # Publish completion event - self.event_bus.publish_sync(PipelineCompletedEvent( - event_type=EventType.PIPELINE_COMPLETED, - source_component="pipeline_executor", - correlation_id=event.correlation_id, - pipeline_name=event.pipeline_name, - execution_id=execution_id, - final_result=result, - steps_executed=self._extract_step_names(pipeline), - total_duration=0.0 # Would be calculated in real implementation - )) - + self.event_bus.publish_sync( + PipelineCompletedEvent( + event_type=EventType.PIPELINE_COMPLETED, + source_component="pipeline_executor", + correlation_id=event.correlation_id, + pipeline_name=event.pipeline_name, + execution_id=execution_id, + final_result=result, + steps_executed=self._extract_step_names(pipeline), + total_duration=0.0, # Would be calculated in real implementation + ) + ) + if self.logger: self.logger.info(f"Pipeline execution completed: {event.pipeline_name} [{execution_id}]") else: # Pipeline failed, publish failure event - self._publish_pipeline_failed(event, execution_id, "execution", - result.get("error", "Pipeline execution failed")) + self._publish_pipeline_failed( + event, + execution_id, + "execution", + result.get("error", "Pipeline execution failed"), + ) if self.logger: self.logger.error(f"Pipeline execution failed: {event.pipeline_name} [{execution_id}]") - + # Clean up if execution_id in self.active_executions: del self.active_executions[execution_id] - + except Exception as e: self._publish_pipeline_failed(event, execution_id, "execution", str(e)) - + # Clean up if execution_id in self.active_executions: del self.active_executions[execution_id] - - def _execute_pipeline_real(self, pipeline: Pipeline, event: PipelineRequestedEvent) -> Dict[str, Any]: + + def _execute_pipeline_real(self, pipeline: Pipeline, event: PipelineRequestedEvent) -> dict[str, Any]: """Execute pipeline for real.""" try: if self.logger: self.logger.info(f"Executing pipeline: {pipeline.name}") - + # Execute the actual pipeline - pipeline.execute_pipeline( - additional_context=event.execution_context, - next_manifest=event.stack_manifest - ) - + pipeline.execute_pipeline(additional_context=event.execution_context, next_manifest=event.stack_manifest) + # Return pipeline context as result return { "success": True, "pipeline": pipeline.name, "context": pipeline.context, - "execution_context": event.execution_context + "execution_context": event.execution_context, } - + except Exception as e: if self.logger: self.logger.error(f"Pipeline execution failed: {pipeline.name} - {e}") - + return { "success": False, "pipeline": pipeline.name, "error": str(e), - "context": getattr(pipeline, 'context', {}), - "execution_context": event.execution_context + "context": getattr(pipeline, "context", {}), + "execution_context": event.execution_context, } - + def _extract_step_names(self, pipeline: Pipeline) -> list: """Extract step names from pipeline for reporting.""" step_names = [] @@ -298,69 +311,80 @@ def _extract_step_names(self, pipeline: Pipeline) -> list: except Exception: pass return step_names - - def _publish_pipeline_failed(self, event: PipelineRequestedEvent, execution_id: str, - failure_step: str, error_message: str): + + def _publish_pipeline_failed( + self, + event: PipelineRequestedEvent, + execution_id: str, + failure_step: str, + error_message: str, + ): """Publish pipeline failure event.""" - self.event_bus.publish_sync(PipelineFailedEvent( - event_type=EventType.PIPELINE_FAILED, - source_component="pipeline_executor", - correlation_id=event.correlation_id, - pipeline_name=event.pipeline_name, - execution_id=execution_id, - failure_step=failure_step, - error_details={"error": error_message}, - compensation_executed=False - )) - + self.event_bus.publish_sync( + PipelineFailedEvent( + event_type=EventType.PIPELINE_FAILED, + source_component="pipeline_executor", + correlation_id=event.correlation_id, + pipeline_name=event.pipeline_name, + execution_id=execution_id, + failure_step=failure_step, + error_details={"error": error_message}, + compensation_executed=False, + ) + ) + if self.logger: self.logger.error(f"Pipeline execution failed: {event.pipeline_name} [{execution_id}] - {error_message}") class PipelineEngine: """Main pipeline engine subsystem coordinator.""" - - def __init__(self, event_bus: EventBus, config_path: Optional[str] = None, logger=None): + + def __init__(self, event_bus: EventBus, config_path: str | None = None, logger=None): self.event_bus = event_bus self.logger = logger - + # Initialize components self.manager = PipelineManager(config_path, logger) self.executor = PipelineExecutor(event_bus, self.manager, logger) - + if self.logger: self.logger.info("PipelineEngine subsystem initialized") - + def get_manager(self) -> PipelineManager: """Get pipeline manager.""" return self.manager - + def get_executor(self) -> PipelineExecutor: """Get pipeline executor.""" return self.executor - - def execute_pipeline(self, pipeline_name: str, additional_context: Optional[Dict] = None, - stack_manifest: Optional[Dict] = None): + + def execute_pipeline( + self, + pipeline_name: str, + additional_context: dict | None = None, + stack_manifest: dict | None = None, + ): """Execute a pipeline directly (legacy interface).""" try: pipeline = self.manager.get_pipeline(pipeline_name) if pipeline: if self.logger: self.logger.info(f"Executing pipeline: {pipeline_name} with context: {additional_context}") - + # Create synthetic pipeline request event pipeline_event = PipelineRequestedEvent( event_type=EventType.PIPELINE_REQUESTED, source_component="pipeline_engine_legacy", pipeline_name=pipeline_name, execution_context=additional_context or {}, - stack_manifest=stack_manifest or {} + stack_manifest=stack_manifest or {}, ) - + self.executor._execute_pipeline_internal(pipeline_event) else: if self.logger: self.logger.warning(f"No pipeline found with name: {pipeline_name}") except Exception as e: if self.logger: - self.logger.error(f"Error executing pipeline: {e}") \ No newline at end of file + self.logger.error(f"Error executing pipeline: {e}") diff --git a/composer/subsystems/stack_manager.py b/muto_composer/subsystems/stack_manager.py similarity index 88% rename from composer/subsystems/stack_manager.py rename to muto_composer/subsystems/stack_manager.py index 011a206..3fbc429 100644 --- a/composer/subsystems/stack_manager.py +++ b/muto_composer/subsystems/stack_manager.py @@ -16,27 +16,35 @@ Handles stack states, analysis, and transformations. """ +import json import os import re -import json -from typing import Dict, Any, Optional -from enum import Enum from dataclasses import dataclass +from enum import Enum +from typing import Any + from ament_index_python.packages import get_package_share_directory -from composer.model.stack import Stack -from composer.utils.stack_parser import create_stack_parser -from composer.events import ( - EventBus, EventType, StackRequestEvent, StackAnalyzedEvent, - StackMergedEvent, StackTransformedEvent, StackProcessedEvent, - OrchestrationFailedEvent + +from muto_composer.events import ( + EventBus, + EventType, + OrchestrationFailedEvent, + StackAnalyzedEvent, + StackMergedEvent, + StackProcessedEvent, + StackRequestEvent, + StackTransformedEvent, ) -from composer.state.persistence import StatePersistence +from muto_composer.model.stack import Stack +from muto_composer.state.persistence import StatePersistence +from muto_composer.utils.stack_parser import create_stack_parser class StackType(Enum): """Enumeration of stack types.""" + ARCHIVE = "stack/archive" - JSON = "stack/json" + JSON = "stack/json" RAW = "stack/raw" LEGACY = "stack/legacy" UNKNOWN = "stack/unknown" @@ -45,28 +53,30 @@ class StackType(Enum): @dataclass class ExecutionRequirements: """Stack execution requirements.""" + requires_provision: bool = False requires_launch: bool = False has_nodes: bool = False has_composables: bool = False has_launch_description: bool = False - - def to_dict(self) -> Dict[str, Any]: + + def to_dict(self) -> dict[str, Any]: """Convert to dictionary.""" return { "requires_provision": self.requires_provision, "requires_launch": self.requires_launch, "has_nodes": self.has_nodes, "has_composables": self.has_composables, - "has_launch_description": self.has_launch_description + "has_launch_description": self.has_launch_description, } @dataclass class StackTransition: """Represents a transition between stack states.""" - current: Optional[Dict[str, Any]] = None - next: Optional[Dict[str, Any]] = None + + current: dict[str, Any] | None = None + next: dict[str, Any] | None = None transition_type: str = "deploy" @@ -76,9 +86,9 @@ class StackStateManager: def __init__(self, event_bus: EventBus, logger=None): self.event_bus = event_bus self.logger = logger - self.current_stack: Optional[Dict] = None - self.next_stack: Optional[Dict] = None - self._current_stack_name: Optional[str] = None + self.current_stack: dict | None = None + self.next_stack: dict | None = None + self._current_stack_name: str | None = None # Initialize state persistence self.persistence = StatePersistence(logger=logger) @@ -91,31 +101,31 @@ def __init__(self, event_bus: EventBus, logger=None): if self.logger: self.logger.info("StackStateManager initialized with persistence") - def _get_stack_name(self, stack: Optional[Dict]) -> str: + def _get_stack_name(self, stack: dict | None) -> str: """Extract stack name from stack definition.""" if not stack: return "default" metadata = stack.get("metadata", {}) return metadata.get("name", stack.get("name", "default")) - def set_current_stack(self, stack: Dict) -> None: + def set_current_stack(self, stack: dict) -> None: """Update current stack state.""" self.current_stack = stack self._current_stack_name = self._get_stack_name(stack) if self.logger: self.logger.debug("Current stack updated") - def set_next_stack(self, stack: Dict) -> None: + def set_next_stack(self, stack: dict) -> None: """Set stack for next deployment.""" self.next_stack = stack if self.logger: self.logger.debug("Next stack set") - def get_current_stack(self) -> Optional[Dict]: + def get_current_stack(self) -> dict | None: """Get current stack.""" return self.current_stack - def get_next_stack(self) -> Optional[Dict]: + def get_next_stack(self) -> dict | None: """Get next stack.""" return self.next_stack @@ -124,7 +134,7 @@ def get_stack_transition(self) -> StackTransition: return StackTransition( current=self.current_stack, next=self.next_stack, - transition_type=self._determine_transition_type() + transition_type=self._determine_transition_type(), ) def _determine_transition_type(self) -> str: @@ -136,7 +146,7 @@ def _determine_transition_type(self) -> str: else: return "update" - def get_previous_stack(self) -> Optional[Dict]: + def get_previous_stack(self) -> dict | None: """Get the previous stack for rollback.""" if self._current_stack_name: return self.persistence.get_previous_stack(self._current_stack_name) @@ -148,7 +158,7 @@ def can_rollback(self) -> bool: return self.persistence.can_rollback(self._current_stack_name) return False - def mark_deployment_started(self, stack: Dict) -> None: + def mark_deployment_started(self, stack: dict) -> None: """Mark deployment as started and persist state.""" stack_name = self._get_stack_name(stack) self.persistence.mark_deployment_started(stack_name, stack) @@ -164,7 +174,7 @@ def handle_stack_merged(self, event: StackMergedEvent): def handle_orchestration_completed(self, event): """Handle orchestration completion and persist state.""" - if hasattr(event, 'final_stack_state') and event.final_stack_state: + if hasattr(event, "final_stack_state") and event.final_stack_state: self.set_current_stack(event.final_stack_state) stack_name = self._get_stack_name(event.final_stack_state) self.persistence.mark_deployment_completed(stack_name) @@ -174,7 +184,7 @@ def handle_orchestration_completed(self, event): def handle_orchestration_failed(self, event: OrchestrationFailedEvent): """Handle orchestration failure and persist state.""" stack_name = self._current_stack_name or "unknown" - error_msg = getattr(event, 'error_details', str(event)) if hasattr(event, 'error_details') else "Unknown error" + error_msg = getattr(event, "error_details", str(event)) if hasattr(event, "error_details") else "Unknown error" self.persistence.mark_deployment_failed(stack_name, error_msg) if self.logger: self.logger.error(f"Orchestration failed for {stack_name}: {error_msg}") @@ -205,12 +215,12 @@ def __init__(self, event_bus: EventBus, logger=None): if self.logger: self.logger.info("StackAnalyzer initialized") - - def analyze_stack_type(self, stack: Dict) -> StackType: + + def analyze_stack_type(self, stack: dict) -> StackType: """Determine if stack is archive, JSON, raw, or legacy.""" metadata = stack.get("metadata", {}) content_type = metadata.get("content_type", "") - + # Check for new prefixed format first if content_type == StackType.ARCHIVE.value: return StackType.ARCHIVE @@ -236,19 +246,19 @@ def analyze_stack_type(self, stack: Dict) -> StackType: return StackType.LEGACY else: return StackType.UNKNOWN - - def determine_execution_requirements(self, stack: Dict) -> ExecutionRequirements: + + def determine_execution_requirements(self, stack: dict) -> ExecutionRequirements: """Calculate provisioning and launch requirements.""" stack_type = self.analyze_stack_type(stack) - + return ExecutionRequirements( requires_provision=stack_type == StackType.ARCHIVE, requires_launch=stack_type in [StackType.ARCHIVE, StackType.JSON, StackType.RAW], has_nodes=bool(stack.get("node")), has_composables=bool(stack.get("composable")), - has_launch_description=bool(stack.get("launch_description_source")) + has_launch_description=bool(stack.get("launch_description_source")), ) - + def handle_stack_request(self, event: StackRequestEvent): """Handle stack request by analyzing and validating the payload.""" try: @@ -275,16 +285,16 @@ def handle_stack_request(self, event: StackRequestEvent): "is_kill_action": True, "stack_id": stack_id, "requires_provision": False, - "requires_launch": False + "requires_launch": False, }, processing_requirements={ "requires_provision": False, "requires_launch": False, - "is_kill_action": True + "is_kill_action": True, }, stack_payload=stack_payload, correlation_id=event.correlation_id, - metadata={"action": event.action, "stack_id": stack_id} + metadata={"action": event.action, "stack_id": stack_id}, ) if self.logger: @@ -314,19 +324,21 @@ def handle_stack_request(self, event: StackRequestEvent): "requires_launch": requirements.requires_launch, "has_nodes": requirements.has_nodes, "has_composables": requirements.has_composables, - "has_launch_description": requirements.has_launch_description + "has_launch_description": requirements.has_launch_description, }, processing_requirements=requirements.to_dict(), stack_payload=stack_payload, # Use direct field instead of nested structure correlation_id=event.correlation_id, - metadata={"action": event.action} + metadata={"action": event.action}, ) if self.logger: - self.logger.info(f"Analyzed stack as {stack_type.value}, requires_provision={requirements.requires_provision}") + self.logger.info( + f"Analyzed stack as {stack_type.value}, requires_provision={requirements.requires_provision}" + ) self.event_bus.publish_sync(analyzed_event) - + except Exception as e: if self.logger: self.logger.error(f"Error analyzing stack: {e}") @@ -334,18 +346,18 @@ def handle_stack_request(self, event: StackRequestEvent): class StackProcessor: """Handles stack transformations and merging.""" - + def __init__(self, event_bus: EventBus, logger=None): self.event_bus = event_bus self.logger = logger self.stack_parser = create_stack_parser(logger) - + # Subscribe to events that require processing self.event_bus.subscribe(EventType.STACK_ANALYZED, self.handle_stack_analyzed) - + if self.logger: self.logger.info("StackProcessor initialized") - + def handle_stack_analyzed(self, event: StackAnalyzedEvent): """Handle stack analyzed event and perform required processing.""" try: @@ -353,7 +365,7 @@ def handle_stack_analyzed(self, event: StackAnalyzedEvent): stack_payload = event.manifest_data.get("stack_payload", {}) processed_payload = stack_payload processing_applied = False - + # Check if merging is required if processing_requirements.get("merge_manifests", False): # For now, we'll simulate merging with current stack @@ -361,19 +373,19 @@ def handle_stack_analyzed(self, event: StackAnalyzedEvent): current_stack = {} # Would be retrieved from state manager processed_payload = self.merge_stacks(current_stack, processed_payload) processing_applied = True - + if self.logger: self.logger.info("Stack merging completed as required by analysis") - + # Check if expression resolution is required if processing_requirements.get("resolve_expressions", False): resolved_json = self.resolve_expressions(json.dumps(processed_payload)) processed_payload = json.loads(resolved_json) processing_applied = True - + if self.logger: self.logger.info("Expression resolution completed as required by analysis") - + # If any processing was applied, emit a processed event with updated payload if processing_applied: processed_event = StackProcessedEvent( @@ -384,27 +396,29 @@ def handle_stack_analyzed(self, event: StackAnalyzedEvent): action=event.action, stack_payload=processed_payload, original_payload=stack_payload, - processing_applied=list(processing_requirements.keys()) + processing_applied=list(processing_requirements.keys()), ) self.event_bus.publish_async(processed_event) - + if self.logger: - self.logger.info(f"Published processed stack event with applied processing: {processing_requirements}") - + self.logger.info( + f"Published processed stack event with applied processing: {processing_requirements}" + ) + except Exception as e: if self.logger: self.logger.error(f"Error processing analyzed stack: {e}") - - def merge_stacks(self, current: Dict, next: Dict) -> Dict: + + def merge_stacks(self, current: dict, next: dict) -> dict: """Merge current and next stacks intelligently.""" try: if not current: current = {} - + stack_1 = Stack(manifest=current) stack_2 = Stack(manifest=next) merged = stack_1.merge(stack_2) - + # Publish merge event merge_event = StackMergedEvent( event_type=EventType.STACK_MERGED, @@ -412,37 +426,37 @@ def merge_stacks(self, current: Dict, next: Dict) -> Dict: current_stack=current, next_stack=next, stack_payload=merged.manifest, - merge_strategy="intelligent_merge" + merge_strategy="intelligent_merge", ) self.event_bus.publish_sync(merge_event) - + if self.logger: self.logger.info("Successfully merged stacks") - + return merged.manifest - + except Exception as e: if self.logger: self.logger.error(f"Error merging stacks: {e}") return next # Fallback to next stack - - def resolve_expressions(self, stack_json: str, current_stack: Optional[Dict] = None) -> str: + + def resolve_expressions(self, stack_json: str, current_stack: dict | None = None) -> str: """Resolve dynamic expressions in stack definitions.""" try: expressions = re.findall(r"\$\(([\s0-9a-zA-Z_-]+)\)", stack_json) result = stack_json resolved_expressions = {} - + for expression in expressions: parts = expression.split() if len(parts) != 2: if self.logger: self.logger.warning(f"Invalid expression format: {expression}") continue - + expr, var = parts resolved_value = "" - + try: if expr == "find": resolved_value = get_package_share_directory(var) @@ -453,7 +467,7 @@ def resolve_expressions(self, stack_json: str, current_stack: Optional[Dict] = N resolved_value = current_stack.get("args", {}).get(var, "") if self.logger: self.logger.info(f"Resolved arg {var}: {resolved_value}") - + resolved_expressions[expression] = resolved_value result = re.sub( r"\$\(" + re.escape(expression) + r"\)", @@ -465,7 +479,7 @@ def resolve_expressions(self, stack_json: str, current_stack: Optional[Dict] = N if self.logger: self.logger.warning(f"Failed to resolve expression {expression}: {e}") continue - + # Publish transformation event if any expressions were resolved if resolved_expressions: transform_event = StackTransformedEvent( @@ -474,21 +488,21 @@ def resolve_expressions(self, stack_json: str, current_stack: Optional[Dict] = N original_stack=json.loads(stack_json), stack_payload=json.loads(result), expressions_resolved=resolved_expressions, - transformation_type="expression_resolution" + transformation_type="expression_resolution", ) self.event_bus.publish_sync(transform_event) - + if self.logger: self.logger.info(f"Resolved {len(resolved_expressions)} expressions") - + return result - + except Exception as e: if self.logger: self.logger.error(f"Error resolving expressions: {e}") return stack_json # Return original on error - - def parse_payload(self, payload: Dict) -> Dict: + + def parse_payload(self, payload: dict) -> dict: """Parse and normalize different payload formats.""" try: parsed = self.stack_parser.parse_payload(payload) @@ -505,27 +519,27 @@ def parse_payload(self, payload: Dict) -> Dict: class StackManager: """Main stack management subsystem coordinator.""" - + def __init__(self, event_bus: EventBus, logger=None): self.event_bus = event_bus self.logger = logger - + # Initialize components self.state_manager = StackStateManager(event_bus, logger) self.analyzer = StackAnalyzer(event_bus, logger) self.processor = StackProcessor(event_bus, logger) - + if self.logger: self.logger.info("StackManager subsystem initialized") - + def get_state_manager(self) -> StackStateManager: """Get state manager.""" return self.state_manager - + def get_analyzer(self) -> StackAnalyzer: """Get analyzer.""" return self.analyzer - + def get_processor(self) -> StackProcessor: """Get processor.""" - return self.processor \ No newline at end of file + return self.processor diff --git a/composer/subsystems/watchdog.py b/muto_composer/subsystems/watchdog.py similarity index 83% rename from composer/subsystems/watchdog.py rename to muto_composer/subsystems/watchdog.py index eae60c3..39cedca 100644 --- a/composer/subsystems/watchdog.py +++ b/muto_composer/subsystems/watchdog.py @@ -18,20 +18,20 @@ import json import time -from typing import Dict, Any, Optional, List -from enum import IntEnum from dataclasses import dataclass, field -from datetime import datetime +from enum import IntEnum +from typing import Any import rclpy -from rclpy.node import Node from rclpy.callback_groups import ReentrantCallbackGroup -from std_srvs.srv import Trigger +from rclpy.node import Node from std_msgs.msg import String +from std_srvs.srv import Trigger class HealthStatus(IntEnum): """Health status levels for subsystems.""" + HEALTHY = 0 DEGRADED = 1 FAILED = 2 @@ -41,13 +41,14 @@ class HealthStatus(IntEnum): @dataclass class SubsystemHealth: """Health status for a single subsystem.""" + name: str status: HealthStatus = HealthStatus.UNKNOWN message: str = "" - last_check: Optional[float] = None - response_time_ms: Optional[float] = None + last_check: float | None = None + response_time_ms: float | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.name, "status": self.status.name, @@ -60,12 +61,13 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class SystemHealthReport: """Overall system health report.""" + overall_status: HealthStatus = HealthStatus.UNKNOWN - subsystems: Dict[str, SubsystemHealth] = field(default_factory=dict) + subsystems: dict[str, SubsystemHealth] = field(default_factory=dict) timestamp: float = field(default_factory=time.time) uptime_seconds: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "overall_status": self.overall_status.name, "subsystems": {name: sub.to_dict() for name, sub in self.subsystems.items()}, @@ -110,34 +112,30 @@ def __init__( self._callback_group = ReentrantCallbackGroup() # Initialize health tracking - self._subsystem_health: Dict[str, SubsystemHealth] = {} + self._subsystem_health: dict[str, SubsystemHealth] = {} for name, _ in self.SERVICES_TO_MONITOR: self._subsystem_health[name] = SubsystemHealth(name=name) # Create health status publisher - self._health_pub = self.create_publisher( - String, - "composer_watchdog/health_status", - 10 - ) + self._health_pub = self.create_publisher(String, "composer_watchdog/health_status", 10) # Create health check service self._health_service = self.create_service( Trigger, "composer_watchdog/check_health", self._handle_health_check, - callback_group=self._callback_group + callback_group=self._callback_group, ) # Create service clients for each monitored service - self._service_clients: Dict[str, Any] = {} + self._service_clients: dict[str, Any] = {} self._init_service_clients() # Create periodic health check timer self._check_timer = self.create_timer( self.check_interval_sec, self._periodic_health_check, - callback_group=self._callback_group + callback_group=self._callback_group, ) self.get_logger().info( @@ -156,17 +154,11 @@ def _check_service_availability(self, service_name: str) -> bool: service_names_and_types = self.get_service_names_and_types() full_service_name = f"/{service_name}" if not service_name.startswith("/") else service_name - for svc_name, _ in service_names_and_types: - if svc_name == full_service_name: - return True - return False + return any(svc_name == full_service_name for svc_name, _ in service_names_and_types) def _perform_health_check(self) -> SystemHealthReport: """Perform health check on all monitored subsystems.""" - report = SystemHealthReport( - timestamp=time.time(), - uptime_seconds=time.time() - self.start_time - ) + report = SystemHealthReport(timestamp=time.time(), uptime_seconds=time.time() - self.start_time) healthy_count = 0 degraded_count = 0 @@ -224,22 +216,13 @@ def _periodic_health_check(self) -> None: self._health_pub.publish(msg) # Log summary - status_summary = ", ".join( - f"{name}={sub.status.name}" - for name, sub in report.subsystems.items() - ) - self.get_logger().info( - f"Health check complete: {report.overall_status.name} [{status_summary}]" - ) + status_summary = ", ".join(f"{name}={sub.status.name}" for name, sub in report.subsystems.items()) + self.get_logger().info(f"Health check complete: {report.overall_status.name} [{status_summary}]") except Exception as e: self.get_logger().error(f"Error during health check: {e}") - def _handle_health_check( - self, - request: Trigger.Request, - response: Trigger.Response - ) -> Trigger.Response: + def _handle_health_check(self, request: Trigger.Request, response: Trigger.Response) -> Trigger.Response: """Handle on-demand health check service requests.""" try: report = self._perform_health_check() @@ -257,7 +240,7 @@ def get_health_report(self) -> SystemHealthReport: """Get current health report (for programmatic access).""" return self._perform_health_check() - def get_subsystem_health(self, name: str) -> Optional[SubsystemHealth]: + def get_subsystem_health(self, name: str) -> SubsystemHealth | None: """Get health status for a specific subsystem.""" return self._subsystem_health.get(name) diff --git a/composer/utils/__init__.py b/muto_composer/utils/__init__.py similarity index 69% rename from composer/utils/__init__.py rename to muto_composer/utils/__init__.py index 25bf4e6..b649156 100644 --- a/composer/utils/__init__.py +++ b/muto_composer/utils/__init__.py @@ -4,4 +4,4 @@ from .stack_parser import StackParser, create_stack_parser -__all__ = ["StackParser", "create_stack_parser"] \ No newline at end of file +__all__ = ["StackParser", "create_stack_parser"] diff --git a/composer/utils/paths.py b/muto_composer/utils/paths.py similarity index 100% rename from composer/utils/paths.py rename to muto_composer/utils/paths.py diff --git a/composer/utils/stack_parser.py b/muto_composer/utils/stack_parser.py similarity index 85% rename from composer/utils/stack_parser.py rename to muto_composer/utils/stack_parser.py index a713a26..82bab54 100644 --- a/composer/utils/stack_parser.py +++ b/muto_composer/utils/stack_parser.py @@ -5,44 +5,44 @@ including direct stack payloads, solution manifests, and archive formats. """ -import json import base64 import gzip import io -from typing import Optional, Dict, Any +import json import logging +from typing import Any class StackParser: """ Utility class for parsing and extracting stack definitions from various payload formats. - + Supports: - Direct stack JSON payloads (new format) - - Archive stack payloads (new format) + - Archive stack payloads (new format) - Solution manifest payloads with embedded stack components (old format) - Base64 encoded and compressed stack data """ - - def __init__(self, logger: Optional[logging.Logger] = None): + + def __init__(self, logger: logging.Logger | None = None): """ Initialize the StackParser. - + Args: logger: Optional logger instance for debugging and error reporting """ self.logger = logger or logging.getLogger(__name__) - - def parse_payload(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + def parse_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: """ Main entry point for parsing stack payloads. - + Args: payload: The payload dictionary to parse - + Returns: Parsed stack dictionary if successful, original payload if has "value" key, None otherwise - + Rules: - If payload has a "value" key, return payload as-is (no parsing needed) - Otherwise, attempt to extract stack from various formats @@ -50,40 +50,40 @@ def parse_payload(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not isinstance(payload, dict): self.logger.warning("Payload is not a dictionary") return None - + # If payload has a value key, return as-is (existing behavior) if "value" in payload: self.logger.debug("Payload contains 'value' key, returning as-is") return payload - + # Try different parsing strategies in order of preference # 1. Try new direct stack format (stack.json style) stack = self._try_direct_stack_json(payload) if stack: return stack - + # 2. Try new archive format (stack-archive-example.json style) stack = self._try_archive_format(payload) if stack: return stack - + # 3. Try old solution manifest format (talker-listener-archive-solution.json style) stack = self._try_solution_manifest(payload) if stack: return stack - #if payload is dictionary and has node or composable return the payloadas is + # if payload is dictionary and has node or composable return the payloadas is if isinstance(payload, dict) and ("node" in payload or "composable" in payload): self.logger.debug("Payload contains 'node' or 'composable', returning as-is") return payload - + self.logger.warning("Could not parse stack from payload") return None - - def _try_direct_stack_json(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + def _try_direct_stack_json(self, payload: dict[str, Any]) -> dict[str, Any] | None: """ Try to parse payload as a direct stack/json definition. - + Direct stack format example (stack.json): { "metadata": { @@ -98,16 +98,16 @@ def _try_direct_stack_json(self, payload: Dict[str, Any]) -> Optional[Dict[str, """ metadata = payload.get("metadata", {}) content_type = metadata.get("content_type") - + if content_type == "stack/json": return payload - + return None - - def _try_archive_format(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + def _try_archive_format(self, payload: dict[str, Any]) -> dict[str, Any] | None: """ Try to parse payload as an archive format stack. - + Archive format example (stack-archive-example.json): { "metadata": { @@ -122,16 +122,16 @@ def _try_archive_format(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any """ metadata = payload.get("metadata", {}) content_type = metadata.get("content_type") - + if content_type == "stack/archive": return payload - + return None - - def _try_solution_manifest(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + def _try_solution_manifest(self, payload: dict[str, Any]) -> dict[str, Any] | None: """ Try to parse payload as a solution manifest with embedded stack components. - + This handles the existing _extract_stack_from_solution logic for the old format. Solution manifest format (talker-listener-archive-solution.json): { @@ -161,7 +161,7 @@ def _try_solution_manifest(self, payload: Dict[str, Any]) -> Optional[Dict[str, properties = component.get("properties", {}) if properties.get("type") != "stack": continue - + data_b64 = properties.get("data") if not data_b64: continue @@ -175,22 +175,22 @@ def _try_solution_manifest(self, payload: Dict[str, Any]) -> Optional[Dict[str, self.logger.warning( f"Failed to decode stack component '{component.get('name', '')}' from solution: {exc}" ) - + return None - - def _decode_base64_stack(self, data_b64: str) -> Optional[Dict[str, Any]]: + + def _decode_base64_stack(self, data_b64: str) -> dict[str, Any] | None: """ Decode base64 encoded stack data, handling compression if present. - + Args: data_b64: Base64 encoded stack data - + Returns: Decoded stack dictionary if successful, None otherwise """ try: raw = base64.b64decode(data_b64) - + # Handle potential gzip compression while True: try: @@ -204,12 +204,12 @@ def _decode_base64_stack(self, data_b64: str) -> Optional[Dict[str, Any]]: raw = gz.read() continue raise - + except (ValueError, json.JSONDecodeError, OSError) as exc: self.logger.warning(f"Failed to decode base64 stack data: {exc}") return None - - def validate_stack(self, stack: Dict[str, Any]) -> bool: + + def validate_stack(self, stack: dict[str, Any]) -> bool: """ Validate that the parsed result is a valid stack definition. @@ -229,7 +229,13 @@ def validate_stack(self, stack: Dict[str, Any]) -> bool: return True # Basic validation for legacy formats - at least one of these should be present - required_fields = ["node", "composable", "launch_description_source", "artifact", "archive_properties"] + required_fields = [ + "node", + "composable", + "launch_description_source", + "artifact", + "archive_properties", + ] optional_fields = ["stackId", "name", "context", "on_start", "on_kill", "content_type"] has_required = any(field in stack for field in required_fields) @@ -238,14 +244,14 @@ def validate_stack(self, stack: Dict[str, Any]) -> bool: return has_required or has_structure -def create_stack_parser(logger: Optional[logging.Logger] = None) -> StackParser: +def create_stack_parser(logger: logging.Logger | None = None) -> StackParser: """ Factory function to create a StackParser instance. - + Args: logger: Optional logger instance - + Returns: StackParser instance """ - return StackParser(logger) \ No newline at end of file + return StackParser(logger) diff --git a/composer/workflow/__init__.py b/muto_composer/workflow/__init__.py similarity index 100% rename from composer/workflow/__init__.py rename to muto_composer/workflow/__init__.py diff --git a/composer/workflow/launcher.py b/muto_composer/workflow/launcher.py similarity index 83% rename from composer/workflow/launcher.py rename to muto_composer/workflow/launcher.py index 1e612b1..87237f8 100644 --- a/composer/workflow/launcher.py +++ b/muto_composer/workflow/launcher.py @@ -12,25 +12,22 @@ # +import asyncio +import concurrent.futures +import contextlib +import multiprocessing import os import signal +from collections import OrderedDict + import launch -import multiprocessing -import concurrent.futures import rclpy.logging -from collections import OrderedDict -from typing import ( - List, - Text, - Tuple, - Dict -) from launch import LaunchDescription, LaunchService -import asyncio from launch.actions import RegisterEventHandler -from launch.event_handlers import OnProcessStart, OnProcessExit +from launch.event_handlers import OnProcessExit, OnProcessStart from launch_ros.actions import Node -from composer.introspection.model.difference import Difference + +from muto_composer.introspection.model.difference import Difference class Ros2LaunchParent: @@ -51,7 +48,7 @@ def __del__(self): # Cleanly shut down the multiprocessing manager self.manager.shutdown() - def parse_launch_arguments(self, launch_arguments: List[Text]) -> List[Tuple[Text, Text]]: + def parse_launch_arguments(self, launch_arguments: list[str]) -> list[tuple[str, str]]: """ Parse the given launch arguments from the command line into a list of (key, value) pairs. @@ -60,12 +57,10 @@ def parse_launch_arguments(self, launch_arguments: List[Text]) -> List[Tuple[Tex """ parsed_launch_arguments = OrderedDict() for argument in launch_arguments: - count = argument.count(':=') - if count == 0 or argument.startswith(':=') or (count == 1 and argument.endswith(':=')): - raise RuntimeError( - f"malformed launch argument '{argument}', expected format ':='" - ) - name, value = argument.split(':=', maxsplit=1) + count = argument.count(":=") + if count == 0 or argument.startswith(":=") or (count == 1 and argument.endswith(":=")): + raise RuntimeError(f"malformed launch argument '{argument}', expected format ':='") + name, value = argument.split(":=", maxsplit=1) # If the same argument name appears multiple times, last one wins parsed_launch_arguments[name] = value return parsed_launch_arguments.items() @@ -77,7 +72,8 @@ def start(self, launch_description: LaunchDescription): """ self._stop_event = multiprocessing.Event() self._process = multiprocessing.Process( - target=self._run_process, args=(self._stop_event, launch_description), daemon=True) + target=self._run_process, args=(self._stop_event, launch_description), daemon=True + ) self._process.start() def _run_process(self, stop_event, launch_description): @@ -92,18 +88,14 @@ def _run_process(self, stop_event, launch_description): launch_description.add_action( RegisterEventHandler( OnProcessStart( - on_start=lambda event, context: self._event_handler( - 'start', event, self._active_nodes, self._lock - ) + on_start=lambda event, context: self._event_handler("start", event, self._active_nodes, self._lock) ) ) ) launch_description.add_action( RegisterEventHandler( OnProcessExit( - on_exit=lambda event, context: self._event_handler( - 'exit', event, self._active_nodes, self._lock - ) + on_exit=lambda event, context: self._event_handler("exit", event, self._active_nodes, self._lock) ) ) ) @@ -115,11 +107,9 @@ def _run_process(self, stop_event, launch_description): # In unit tests, asyncio.new_event_loop may be patched to a mock. # If so, avoid creating/awaiting real coroutines to prevent warnings. if is_mock_loop: - try: + with contextlib.suppress(Exception): # Close the coroutine to prevent "never awaited" warnings run_coro.close() - except Exception: - pass return launch_task = loop.create_task(run_coro) @@ -138,14 +128,13 @@ async def wait_for_stop_event(): finally: loop.close() - async def launch_a_launch_file( self, launch_file_path: str, - launch_file_arguments: List[str], + launch_file_arguments: list[str], noninteractive: bool = False, debug: bool = False, - dry_run: bool = False + dry_run: bool = False, ): """ Launch a given launch file (by path) and pass it the given launch file arguments. @@ -162,38 +151,30 @@ async def launch_a_launch_file( f"Launching file: {launch_file_path} with arguments: {launch_file_arguments}" ) - launch_service = launch.LaunchService( - argv=launch_file_arguments, - noninteractive=noninteractive, - debug=debug - ) + launch_service = launch.LaunchService(argv=launch_file_arguments, noninteractive=noninteractive, debug=debug) parsed_launch_arguments = self.parse_launch_arguments(launch_file_arguments) - launch_description = launch.LaunchDescription([ - launch.actions.IncludeLaunchDescription( - launch.launch_description_sources.AnyLaunchDescriptionSource( - launch_file_path + launch_description = launch.LaunchDescription( + [ + launch.actions.IncludeLaunchDescription( + launch.launch_description_sources.AnyLaunchDescriptionSource(launch_file_path), + launch_arguments=parsed_launch_arguments, ), - launch_arguments=parsed_launch_arguments, - ), - ]) + ] + ) launch_description.add_action( RegisterEventHandler( OnProcessStart( - on_start=lambda event, context: self._event_handler( - 'start', event, self._active_nodes, self._lock - ) + on_start=lambda event, context: self._event_handler("start", event, self._active_nodes, self._lock) ) ) ) launch_description.add_action( RegisterEventHandler( OnProcessExit( - on_exit=lambda event, context: self._event_handler( - 'exit', event, self._active_nodes, self._lock - ) + on_exit=lambda event, context: self._event_handler("exit", event, self._active_nodes, self._lock) ) ) ) @@ -209,10 +190,10 @@ async def launch_a_launch_file( async def launch_a_launch_description( self, launch_description: launch.LaunchDescription, - launch_file_arguments: List[str] = None, + launch_file_arguments: list[str] = None, noninteractive: bool = False, debug: bool = False, - dry_run: bool = False + dry_run: bool = False, ): """ Launch a given LaunchDescription with optional arguments. @@ -232,28 +213,20 @@ async def launch_a_launch_description( f"Launching LaunchDescription with arguments: {launch_file_arguments}" ) - launch_service = launch.LaunchService( - argv=launch_file_arguments, - noninteractive=noninteractive, - debug=debug - ) + launch_service = launch.LaunchService(argv=launch_file_arguments, noninteractive=noninteractive, debug=debug) # Register the same event handlers so we can track node processes launch_description.add_action( RegisterEventHandler( OnProcessStart( - on_start=lambda event, context: self._event_handler( - 'start', event, self._active_nodes, self._lock - ) + on_start=lambda event, context: self._event_handler("start", event, self._active_nodes, self._lock) ) ) ) launch_description.add_action( RegisterEventHandler( OnProcessExit( - on_exit=lambda event, context: self._event_handler( - 'exit', event, self._active_nodes, self._lock - ) + on_exit=lambda event, context: self._event_handler("exit", event, self._active_nodes, self._lock) ) ) ) @@ -323,7 +296,7 @@ def kill_node(node): ) self._process.terminate() - def kill_nodes_by_name(self, node_names: List[str]): + def kill_nodes_by_name(self, node_names: list[str]): """ Kills only the active nodes whose process_name is in node_names. If your node names differ from the process_name assigned by ROS 2, @@ -367,7 +340,9 @@ def kill_node_if_needed(node): # Optionally, if no nodes remain, shut down the service if not self._active_nodes and self._process: - rclpy.logging.get_logger("muto_launch_parent").info("All requested nodes killed, shutting down the launch service.") + rclpy.logging.get_logger("muto_launch_parent").info( + "All requested nodes killed, shutting down the launch service." + ) if self._stop_event: self._stop_event.set() self._process.join(timeout=10.0) @@ -383,28 +358,23 @@ def _event_handler(self, action, event, nodes_list, lock): Maintains a list of active nodes as a shared list of dicts: [{process_name: pid}, ...] """ with lock: - if action == 'start': + if action == "start": rclpy.logging.get_logger("muto_launch_parent").info( f"Node started: {event.process_name} with PID {event.pid}" ) nodes_list.append({event.process_name: event.pid}) - elif action == 'exit': + elif action == "exit": rclpy.logging.get_logger("muto_launch_parent").info( f"Node exited: {event.process_name} with PID {event.pid}" ) - nodes_list[:] = [ - node for node in nodes_list - if node.get(event.process_name) != event.pid - ] + nodes_list[:] = [node for node in nodes_list if node.get(event.process_name) != event.pid] - rclpy.logging.get_logger("muto_launch_parent").info( - f"Active nodes after {action}: {nodes_list}" - ) - if not nodes_list and action == 'exit': + rclpy.logging.get_logger("muto_launch_parent").info(f"Active nodes after {action}: {nodes_list}") + if not nodes_list and action == "exit": self.shutdown() def create_launch_description_for_added_nodes( - self, added_nodes: Dict[str, Dict[str, str]] + self, added_nodes: dict[str, dict[str, str]] ) -> launch.LaunchDescription: """ Given 'added_nodes' from Delta, build a LaunchDescription with all the new Node actions. @@ -418,7 +388,7 @@ def create_launch_description_for_added_nodes( } """ ld = launch.LaunchDescription() - for key, node_info in added_nodes.items(): + for _key, node_info in added_nodes.items(): node_name = node_info.get("name", "unnamed_node") node_ns = node_info.get("namespace", "/") pkg = node_info.get("package", "") @@ -438,7 +408,7 @@ def create_launch_description_for_added_nodes( return ld - def apply_delta(self, diff_result: Difference, extra_args: List[str], async_loop: asyncio.AbstractEventLoop): + def apply_delta(self, diff_result: Difference, extra_args: list[str], async_loop: asyncio.AbstractEventLoop): """ Orchestrates: - Launching all 'added' nodes @@ -456,24 +426,18 @@ def apply_delta(self, diff_result: Difference, extra_args: List[str], async_loop ld = self.create_launch_description_for_added_nodes(diff_result.added_nodes) async def _launch(): - await self.launch_a_launch_description( - ld, - launch_file_arguments=extra_args, - noninteractive=True - ) + await self.launch_a_launch_description(ld, launch_file_arguments=extra_args, noninteractive=True) asyncio.run_coroutine_threadsafe(_launch(), async_loop) # 2) Kill 'removed' nodes if diff_result.removed_nodes: removed_names = [] - for key, node_info in diff_result.removed_nodes.items(): + for _key, node_info in diff_result.removed_nodes.items(): removed_names.append(node_info["name"]) if removed_names: - rclpy.logging.get_logger("muto_launch_parent").info( - f"Killing removed nodes: {removed_names}" - ) + rclpy.logging.get_logger("muto_launch_parent").info(f"Killing removed nodes: {removed_names}") self.kill_nodes_by_name(removed_names) # 3) 'common_nodes' remain untouched diff --git a/composer/workflow/pipeline.py b/muto_composer/workflow/pipeline.py similarity index 85% rename from composer/workflow/pipeline.py rename to muto_composer/workflow/pipeline.py index 72e9cb2..0dc26ee 100644 --- a/composer/workflow/pipeline.py +++ b/muto_composer/workflow/pipeline.py @@ -13,12 +13,14 @@ import importlib import json -import uuid -from muto_msgs.msg._stack_manifest import StackManifest + import rclpy -from rclpy.node import Node import rclpy.logging -from composer.workflow.safe_evaluator import SafeEvaluator +from muto_msgs.msg._stack_manifest import StackManifest +from rclpy.node import Node + +from muto_composer.workflow.safe_evaluator import SafeEvaluator + class Pipeline: def __init__(self, name, steps, compensation): @@ -63,28 +65,24 @@ def load_plugins(self) -> dict: try: plugin_class = getattr(module, plugin_name) plugin_dict[plugin_name] = plugin_class - except AttributeError: - self.logger.error( - f"Plugin '{plugin_name}' not found in '{module_name}'" - ) + except AttributeError as exc: + self.logger.error(f"Plugin '{plugin_name}' not found in '{module_name}'") raise Exception( f"Plugin '{plugin_name}' not found in module '{module_name}'. " "Ensure the plugin has a corresponding service definition." - ) + ) from exc for step in self.compensation: plugin_name = step.get("plugin") if plugin_name and plugin_name not in plugin_dict: try: plugin_class = getattr(module, plugin_name) plugin_dict[plugin_name] = plugin_class - except AttributeError: - self.logger.error( - f"Compensation Plugin '{plugin_name}' not found in '{module_name}'" - ) + except AttributeError as exc: + self.logger.error(f"Compensation Plugin '{plugin_name}' not found in '{module_name}'") raise Exception( f"Compensation Plugin '{plugin_name}' not found in module '{module_name}'. " "Ensure the plugin has a corresponding service definition." - ) + ) from exc return plugin_dict def execute_pipeline(self, additional_context: dict = None, next_manifest=None): @@ -116,7 +114,9 @@ def execute_pipeline(self, additional_context: dict = None, next_manifest=None): evaluator = SafeEvaluator(self.context) try: should_execute = evaluator.eval_expr(condition) - self.logger.debug(f"Evaluating condition for step '{step_name}': {condition} => {should_execute}") + self.logger.debug( + f"Evaluating condition for step '{step_name}': {condition} => {should_execute}" + ) if not should_execute: self.logger.info(f"Skipping step '{step_name}' due to condition: {condition}") continue @@ -128,13 +128,18 @@ def execute_pipeline(self, additional_context: dict = None, next_manifest=None): break try: - response = self.execute_step(step, executor, inputManifest=input_manifest) if not response: - response = type("Response", (), {"success": False, "err_msg": "No response from service."})() + response = type( + "Response", + (), + {"success": False, "err_msg": "No response from service."}, + )() self.logger.error(f"Step {step_name} failed due to no response.") # Store the response in context regardless of success or failure - self.context[step_name] = type("Response", (), { "success": response.success, "err_msg": response.err_msg })() + self.context[step_name] = type( + "Response", (), {"success": response.success, "err_msg": response.err_msg} + )() if not response.success: raise Exception(f"Step execution error: {response.err_msg}") @@ -148,7 +153,6 @@ def execute_pipeline(self, additional_context: dict = None, next_manifest=None): self.logger.error("Aborting the rest of the pipeline") break - executor.destroy_node() def execute_step(self, step, executor: Node, inputManifest=None): @@ -179,15 +183,13 @@ def execute_step(self, step, executor: Node, inputManifest=None): self.logger.info(f"Executing step: {plugin_name}") if not cli.wait_for_service(timeout_sec=5.0): - self.logger.error( - f"Service '{cli.srv_name}' is not available. Cannot execute step." - ) + self.logger.error(f"Service '{cli.srv_name}' is not available. Cannot execute step.") return None req = plugin.Request() if inputManifest: req.input.current = inputManifest - + future = cli.call_async(req) rclpy.spin_until_future_complete(executor, future) @@ -209,21 +211,19 @@ def execute_compensation(self, executor: Node): try: self.execute_step(step, executor) except Exception as e: - self.logger.warn( - f"Compensation step failed: {step.get('plugin', '')}, Exception: {e}" - ) + self.logger.warn(f"Compensation step failed: {step.get('plugin', '')}, Exception: {e}") else: self.logger.warn("No compensation steps to execute.") - + def toStackManifest(self, manifest): if manifest is None: return None stack_msg = StackManifest() # Handle both old format (name at root) and new format (metadata.name) if isinstance(manifest, dict): - if 'metadata' in manifest and 'name' in manifest['metadata']: - stack_msg.name = manifest['metadata']['name'] + if "metadata" in manifest and "name" in manifest["metadata"]: + stack_msg.name = manifest["metadata"]["name"] else: stack_msg.name = manifest.get("name", "") stack_msg.stack = json.dumps(manifest) - return stack_msg + return stack_msg diff --git a/composer/workflow/router.py b/muto_composer/workflow/router.py similarity index 95% rename from composer/workflow/router.py rename to muto_composer/workflow/router.py index fc2b8ff..56795fa 100644 --- a/composer/workflow/router.py +++ b/muto_composer/workflow/router.py @@ -12,7 +12,9 @@ # import rclpy.logging -from composer.workflow.pipeline import Pipeline + +from muto_composer.workflow.pipeline import Pipeline + class Router: def __init__(self, pipelines): diff --git a/composer/workflow/safe_evaluator.py b/muto_composer/workflow/safe_evaluator.py similarity index 93% rename from composer/workflow/safe_evaluator.py rename to muto_composer/workflow/safe_evaluator.py index 1f14a9e..62d41e6 100644 --- a/composer/workflow/safe_evaluator.py +++ b/muto_composer/workflow/safe_evaluator.py @@ -14,11 +14,13 @@ import ast import operator as op + class SafeEvaluator: """ A safe evaluator for simple expressions used in conditions. Supports basic comparisons and logical operations. """ + operators = { ast.Eq: op.eq, ast.NotEq: op.ne, @@ -39,10 +41,10 @@ def eval_expr(self, expr): Evaluate an expression in the given context. """ try: - expr_ast = ast.parse(expr, mode='eval').body + expr_ast = ast.parse(expr, mode="eval").body return self._eval(expr_ast) except Exception as e: - raise ValueError(f"Invalid condition expression '{expr}': {e}") + raise ValueError(f"Invalid condition expression '{expr}': {e}") from e def _eval(self, node): if isinstance(node, ast.BoolOp): @@ -55,7 +57,7 @@ def _eval(self, node): return not self._eval(node.operand) elif isinstance(node, ast.Compare): left = self._eval(node.left) - for op_, right in zip(node.ops, node.comparators): + for op_, right in zip(node.ops, node.comparators, strict=False): right_val = self._eval(right) oper = self.operators[type(op_)] if not oper(left, right_val): @@ -70,4 +72,4 @@ def _eval(self, node): elif isinstance(node, ast.Constant): return node.value else: - raise TypeError(f"Unsupported expression: {ast.dump(node)}") \ No newline at end of file + raise TypeError(f"Unsupported expression: {ast.dump(node)}") diff --git a/composer/workflow/schemas/__init__.py b/muto_composer/workflow/schemas/__init__.py similarity index 100% rename from composer/workflow/schemas/__init__.py rename to muto_composer/workflow/schemas/__init__.py diff --git a/composer/workflow/schemas/pipeline_schema.py b/muto_composer/workflow/schemas/pipeline_schema.py similarity index 100% rename from composer/workflow/schemas/pipeline_schema.py rename to muto_composer/workflow/schemas/pipeline_schema.py diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..976ba02 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +ignore_missing_imports = True diff --git a/package.xml b/package.xml index 4384f56..727dbde 100644 --- a/package.xml +++ b/package.xml @@ -17,21 +17,31 @@ --> - composer - 0.2.0 - Composer provides orchestration utilites for ROS2 environment + muto_composer + 0.42.0 + Eclipse Muto Composer - stack deployment and orchestration engine that manages provisioning, launching, and lifecycle of ROS 2 software stacks composiv.ai - Eclipse Public License - + EPL-2.0 + + https://eclipse.dev/muto/ + https://github.com/eclipse-muto/composer + https://github.com/eclipse-muto/composer/issues + + ament_python + ament_copyright ament_flake8 ament_pep257 python3-pytest - ament_cmake_pytest - - python3-jsonschema python3-jsonschema + + rclpy + launch_ros + ament_index_python + std_srvs + python3-requests + ament_python diff --git a/resource/composer b/resource/muto_composer similarity index 100% rename from resource/composer rename to resource/muto_composer diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..cc23720 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,6 @@ +line-length = 120 +target-version = "py310" +exclude = ["test/"] + +[lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] diff --git a/setup.cfg b/setup.cfg index e424fd4..9556e62 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,6 @@ [develop] -script_dir=$base/lib/composer +script_dir=$base/lib/muto_composer [install] -install_scripts=$base/lib/composer +install_scripts=$base/lib/muto_composer +[tool:pytest] +testpaths = test diff --git a/setup.py b/setup.py index 014b6c8..bd566c8 100644 --- a/setup.py +++ b/setup.py @@ -12,14 +12,15 @@ # import os -from setuptools import find_packages, setup from glob import glob -PACKAGE_NAME = "composer" +from setuptools import find_packages, setup + +PACKAGE_NAME = "muto_composer" setup( name=PACKAGE_NAME, - version="0.0.0", + version="0.42.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + PACKAGE_NAME]), @@ -27,21 +28,22 @@ (os.path.join("share", PACKAGE_NAME, "config"), glob("config/*.yaml")), (os.path.join("share", PACKAGE_NAME, "launch"), glob("launch/*.launch.py")), ], - install_requires=["docker", "setuptools", "jsonschema"], + install_requires=["setuptools", "jsonschema"], zip_safe=True, maintainer="composiv.ai", maintainer_email="info@composiv.ai", - description="Composer provides orchestration utilites for ROS2 environment", - license="Eclipse Public License", - tests_require=["unittest", "pytest"], - test_suite="test", + description=( + "Eclipse Muto Composer - stack deployment and orchestration engine" + " that manages provisioning, launching, and lifecycle of ROS 2 software stacks" + ), + license="Eclipse Public License v2.0", + python_requires=">=3.10", entry_points={ "console_scripts": [ - "muto_composer = composer.muto_composer:main", - "compose_plugin = composer.plugins.compose_plugin:main", - "provision_plugin = composer.plugins.provision_plugin:main", - "launch_plugin = composer.plugins.launch_plugin:main", - "daemon = composer.introspection.muto_daemon:main", + "muto_composer = muto_composer.muto_composer:main", + "compose_plugin = muto_composer.plugins.compose_plugin:main", + "provision_plugin = muto_composer.plugins.provision_plugin:main", + "launch_plugin = muto_composer.plugins.launch_plugin:main", ], }, ) diff --git a/test/conftest.py b/test/conftest.py index 21b9916..b443878 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,9 +1,9 @@ -import warnings import multiprocessing +import warnings # Use 'spawn' start method to avoid fork() warnings in multi-threaded tests try: - multiprocessing.set_start_method('spawn') + multiprocessing.set_start_method("spawn") except RuntimeError: # start method may already be set by another test pass diff --git a/test/test_architecture_validation.py b/test/test_architecture_validation.py index 7ed47e8..485f6aa 100644 --- a/test/test_architecture_validation.py +++ b/test/test_architecture_validation.py @@ -17,174 +17,174 @@ """ import unittest -from unittest.mock import MagicMock, patch -from composer.events import EventBus, EventType, StackRequestEvent, StackAnalyzedEvent + +from muto_composer.events import EventBus, EventType, StackAnalyzedEvent, StackRequestEvent class TestArchitectureValidation(unittest.TestCase): """Validate the new event-driven architecture.""" - + def setUp(self): self.event_bus = EventBus() - + def test_event_bus_basic_functionality(self): """Test that EventBus works for basic publish/subscribe.""" events_received = [] - + def test_handler(event): events_received.append(event) - + # Subscribe to stack request events self.event_bus.subscribe(EventType.STACK_REQUEST, test_handler) - + # Create and publish an event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Verify event was received self.assertEqual(len(events_received), 1) self.assertEqual(events_received[0].stack_name, "test_stack") self.assertEqual(events_received[0].action, "start") - + def test_multiple_event_types(self): """Test handling multiple event types.""" stack_requests = [] stack_analyzed = [] - + def handle_request(event): stack_requests.append(event) - + def handle_analyzed(event): stack_analyzed.append(event) - + # Subscribe to different event types self.event_bus.subscribe(EventType.STACK_REQUEST, handle_request) self.event_bus.subscribe(EventType.STACK_ANALYZED, handle_analyzed) - + # Publish different types of events request_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="apply" + action="apply", ) - + analyzed_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, source_component="analyzer", stack_name="test_stack", - action="apply" + action="apply", ) - + self.event_bus.publish_sync(request_event) self.event_bus.publish_sync(analyzed_event) - + # Verify each handler only received its event type self.assertEqual(len(stack_requests), 1) self.assertEqual(len(stack_analyzed), 1) self.assertEqual(stack_requests[0].action, "apply") self.assertEqual(stack_analyzed[0].action, "apply") - + def test_event_isolation(self): """Test that events are properly isolated between handlers.""" handler1_events = [] handler2_events = [] - + def handler1(event): handler1_events.append(event) - + def handler2(event): handler2_events.append(event) - + # Subscribe both handlers to same event type self.event_bus.subscribe(EventType.STACK_REQUEST, handler1) self.event_bus.subscribe(EventType.STACK_REQUEST, handler2) - + # Publish event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="isolation_test", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Both handlers should receive the event self.assertEqual(len(handler1_events), 1) self.assertEqual(len(handler2_events), 1) - + # But they should be independent self.assertEqual(handler1_events[0].stack_name, "isolation_test") self.assertEqual(handler2_events[0].stack_name, "isolation_test") - + def test_subsystem_communication_pattern(self): """Test the intended subsystem communication pattern through events.""" # This simulates how subsystems should communicate: # MessageHandler -> StackManager -> OrchestrationManager -> PipelineEngine - + communication_flow = [] - + def message_handler_simulator(event): # Simulate MessageHandler receiving MutoAction and creating StackRequest if event.event_type == EventType.STACK_REQUEST: communication_flow.append("MessageHandler->StackRequest") - + # MessageHandler would publish a StackRequest event # StackManager would subscribe to this and emit StackAnalyzed analyzed_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, source_component="stack_manager", stack_name=event.stack_name, - action=event.action + action=event.action, ) self.event_bus.publish_sync(analyzed_event) - + def stack_manager_simulator(event): if event.event_type == EventType.STACK_ANALYZED: communication_flow.append("StackManager->StackAnalyzed") - + # Would emit OrchestrationStarted, but we'll just track the flow communication_flow.append("StackManager->OrchestrationRequest") - + # Set up the communication chain self.event_bus.subscribe(EventType.STACK_REQUEST, message_handler_simulator) self.event_bus.subscribe(EventType.STACK_ANALYZED, stack_manager_simulator) - + # Start the flow with a StackRequest initial_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test_client", stack_name="communication_test", - action="apply" + action="apply", ) - + self.event_bus.publish_sync(initial_event) - + # Verify the communication flow expected_flow = [ "MessageHandler->StackRequest", - "StackManager->StackAnalyzed", - "StackManager->OrchestrationRequest" + "StackManager->StackAnalyzed", + "StackManager->OrchestrationRequest", ] - + self.assertEqual(communication_flow, expected_flow) - + def test_event_metadata_preservation(self): """Test that event metadata is preserved through the system.""" received_events = [] - + def metadata_handler(event): received_events.append(event) - + self.event_bus.subscribe(EventType.STACK_REQUEST, metadata_handler) - + # Create event with metadata event = StackRequestEvent( event_type=EventType.STACK_REQUEST, @@ -195,12 +195,12 @@ def metadata_handler(event): metadata={ "client_id": "test_client", "priority": "high", - "deployment_target": "edge_device_001" - } + "deployment_target": "edge_device_001", + }, ) - + self.event_bus.publish_sync(event) - + # Verify metadata is preserved received_event = received_events[0] self.assertEqual(received_event.correlation_id, "test_correlation_123") @@ -211,7 +211,7 @@ def metadata_handler(event): class TestStackRequestEventValidation(unittest.TestCase): """Validate StackRequest event functionality.""" - + def test_stack_request_creation(self): """Test creating StackRequest events with different payloads.""" # Test with JSON stack @@ -222,18 +222,15 @@ def test_stack_request_creation(self): action="apply", stack_payload={ "metadata": {"name": "json_test_stack", "content_type": "stack/json"}, - "launch": {"node": [{"name": "test_node", "pkg": "test_pkg"}]} - } + "launch": {"node": [{"name": "test_node", "pkg": "test_pkg"}]}, + }, ) - + self.assertEqual(json_stack_event.stack_name, "json_test_stack") self.assertEqual(json_stack_event.action, "apply") self.assertIn("metadata", json_stack_event.stack_payload) - self.assertEqual( - json_stack_event.stack_payload["metadata"]["content_type"], - "stack/json" - ) - + self.assertEqual(json_stack_event.stack_payload["metadata"]["content_type"], "stack/json") + # Test with archive stack archive_stack_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, @@ -244,18 +241,17 @@ def test_stack_request_creation(self): "metadata": {"name": "archive_test_stack", "content_type": "stack/archive"}, "launch": { "data": "base64_encoded_archive_data", - "properties": {"launch_file": "launch/test.launch.py"} - } - } + "properties": {"launch_file": "launch/test.launch.py"}, + }, + }, ) - + self.assertEqual(archive_stack_event.stack_name, "archive_test_stack") self.assertEqual(archive_stack_event.action, "deploy") self.assertEqual( - archive_stack_event.stack_payload["metadata"]["content_type"], - "stack/archive" + archive_stack_event.stack_payload["metadata"]["content_type"], "stack/archive" ) - + def test_stack_analyzed_event_creation(self): """Test creating StackAnalyzed events.""" analyzed_event = StackAnalyzedEvent( @@ -266,23 +262,22 @@ def test_stack_analyzed_event_creation(self): analysis_result={ "stack_type": "json", "complexity": "medium", - "estimated_resources": {"cpu": "0.5", "memory": "512Mi"} + "estimated_resources": {"cpu": "0.5", "memory": "512Mi"}, }, processing_requirements={ "requires_provision": True, "requires_launch": True, - "execution_order": ["provision", "launch"] - } + "execution_order": ["provision", "launch"], + }, ) - + self.assertEqual(analyzed_event.stack_name, "analyzed_stack") self.assertEqual(analyzed_event.analysis_result["stack_type"], "json") self.assertTrue(analyzed_event.processing_requirements["requires_provision"]) self.assertEqual( - analyzed_event.processing_requirements["execution_order"], - ["provision", "launch"] + analyzed_event.processing_requirements["execution_order"], ["provision", "launch"] ) if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/test_composable.py b/test/test_composable.py index 96915ed..c699a13 100644 --- a/test/test_composable.py +++ b/test/test_composable.py @@ -13,12 +13,12 @@ import unittest from unittest.mock import MagicMock, patch -from composer.model.composable import Container +from muto_composer.model.composable import Container -class TestComposable(unittest.TestCase): - @patch("composer.model.composable.node") +class TestComposable(unittest.TestCase): + @patch("muto_composer.model.composable.node") def test_toManifest(self, mock_node): self.node = Container(MagicMock(), None) returned_value = self.node.toManifest() diff --git a/test/test_compose_plugin.py b/test/test_compose_plugin.py index 61704ff..076fd1b 100644 --- a/test/test_compose_plugin.py +++ b/test/test_compose_plugin.py @@ -11,13 +11,15 @@ # Composiv.ai - initial API and implementation # +import json import os import unittest -import json from unittest.mock import MagicMock, patch + from std_msgs.msg import String -from composer.plugins.compose_plugin import MutoDefaultComposePlugin -from composer.utils.stack_parser import StackParser + +from muto_composer.plugins.compose_plugin import MutoDefaultComposePlugin +from muto_composer.utils.stack_parser import StackParser class TestComposePlugin(unittest.TestCase): @@ -36,7 +38,7 @@ def tearDown(self): def test_handle_raw_stack(self): self.node.incoming_stack = None - distro = os.environ.get('ROS_DISTRO', 'humble') + distro = os.environ.get("ROS_DISTRO", "humble") stack_msg = String( data=f'{{"name": "Muto Run Rototui from repo", "context": "eclipse_muto", "stackId": "org.eclipse.muto.sandbox:muto_repo_test_stack", "url": "https://test_url", "source": {{"ros": "/opt/ros/{distro}/setup.bash", "workspace": "/workspaces/install/setup.bash"}}, "args": {{"launch_muto": "false", "launch_fms": "false", "launch_record_rosbag": "false", "launch_safety_plc": "false", "launch_logger": "false", "launch_css": "false", "vehicle_model": "rototui_vehicle", "sensor_model": "rototui_sensor_kit", "lanelet2_map_file": "64_prod_lanelet_finetuning.osm", "pointcloud_map_file": "pointcloud_map.pcd", "rviz_respawn": "false"}} }}' ) @@ -49,22 +51,22 @@ def test_publish_composed_stack(self, mock_parse_stack): self.node.publish_composed_stack() mock_parse_stack.assert_called_once_with(self.node.incoming_stack) - self.node.composed_stack_publisher.publish.assert_called_once_with( - mock_parse_stack() - ) + self.node.composed_stack_publisher.publish.assert_called_once_with(mock_parse_stack()) def test_handle_compose(self): # Create proper request/response request = MagicMock() - request.input.current.stack = '{"metadata": {"content_type": "stack/json"}, "launch": {"node": []}}' + request.input.current.stack = ( + '{"metadata": {"content_type": "stack/json"}, "launch": {"node": []}}' + ) response = MagicMock() response.success = False response.err_msg = "" - + # Mock handler mock_handler = MagicMock() self.node.stack_registry.get_handler = MagicMock(return_value=mock_handler) - + self.node.handle_compose(request, response) # Test outcome: verify success @@ -74,14 +76,14 @@ def test_handle_compose(self): def test_handle_compose_start_not_set(self): # Test when no handler is found request = MagicMock() - request.input.current.stack = '' + request.input.current.stack = "" response = MagicMock() response.success = True response.err_msg = "" - + # Mock that no handler is found self.node.stack_registry.get_handler = MagicMock(return_value=None) - + self.node.handle_compose(request, response) # Test outcome: verify failure @@ -90,25 +92,27 @@ def test_handle_compose_start_not_set(self): def test_handle_compose_exception(self): # Test exception handling request = MagicMock() - request.input.current.stack = '{"metadata": {"content_type": "stack/json"}, "launch": {"node": []}}' + request.input.current.stack = ( + '{"metadata": {"content_type": "stack/json"}, "launch": {"node": []}}' + ) response = MagicMock() response.success = True response.err_msg = "" - + # Mock handler that raises exception mock_handler = MagicMock() mock_handler.apply_to_plugin.side_effect = Exception("dummy_exception") self.node.stack_registry.get_handler = MagicMock(return_value=mock_handler) - + self.node.handle_compose(request, response) # Test outcome: verify failure and error message contains exception self.assertFalse(response.success) self.assertIn("dummy_exception", response.err_msg) - @patch("composer.plugins.compose_plugin.StackManifest") + @patch("muto_composer.plugins.compose_plugin.StackManifest") def test_parse_stack(self, mock_stack_manifest): - distro = os.environ.get('ROS_DISTRO', 'humble') + distro = os.environ.get("ROS_DISTRO", "humble") stack_msg = String( data=f'{{"name": "Muto Run Rototui from repo", "context": "eclipse_muto", "stackId": "org.eclipse.muto.sandbox:muto_repo_test_stack", "url": "https://test_url", "source": {{"ros": "/opt/ros/{distro}/setup.bash", "workspace": "/workspaces/install/setup.bash"}}, "args": {{"launch_muto": "false", "launch_fms": "false", "launch_record_rosbag": "false", "launch_safety_plc": "false", "launch_logger": "false", "launch_css": "false", "vehicle_model": "rototui_vehicle", "sensor_model": "rototui_sensor_kit", "lanelet2_map_file": "64_prod_lanelet_finetuning.osm", "pointcloud_map_file": "pointcloud_map.pcd", "rviz_respawn": "false"}} }}' ) @@ -117,21 +121,13 @@ def test_parse_stack(self, mock_stack_manifest): self.node.parse_stack(self.node.incoming_stack) mock_stack_manifest.assert_called_once() - self.assertEqual( - mock_stack_manifest().name, self.node.incoming_stack.get("name", "") - ) - self.assertEqual( - mock_stack_manifest().context, self.node.incoming_stack.get("context", "") - ) + self.assertEqual(mock_stack_manifest().name, self.node.incoming_stack.get("name", "")) + self.assertEqual(mock_stack_manifest().context, self.node.incoming_stack.get("context", "")) self.assertEqual( mock_stack_manifest().stack_id, self.node.incoming_stack.get("stackId", "") ) - self.assertEqual( - mock_stack_manifest().url, self.node.incoming_stack.get("url", "") - ) - self.assertEqual( - mock_stack_manifest().branch, self.node.incoming_stack.get("branch", "") - ) + self.assertEqual(mock_stack_manifest().url, self.node.incoming_stack.get("url", "")) + self.assertEqual(mock_stack_manifest().branch, self.node.incoming_stack.get("branch", "")) self.assertEqual( mock_stack_manifest().launch_description_source, self.node.incoming_stack.get("launch_description_source", ""), @@ -139,9 +135,7 @@ def test_parse_stack(self, mock_stack_manifest): self.assertEqual( mock_stack_manifest().on_start, self.node.incoming_stack.get("on_start", "") ) - self.assertEqual( - mock_stack_manifest().on_kill, self.node.incoming_stack.get("on_kill", "") - ) + self.assertEqual(mock_stack_manifest().on_kill, self.node.incoming_stack.get("on_kill", "")) self.assertEqual( mock_stack_manifest().args, json.dumps(self.node.incoming_stack.get("args", "")), diff --git a/test/test_digital_twin_integration.py b/test/test_digital_twin_integration.py index ab51454..782a219 100644 --- a/test/test_digital_twin_integration.py +++ b/test/test_digital_twin_integration.py @@ -11,207 +11,207 @@ # Composiv.ai - initial API and implementation # -import unittest -from unittest.mock import MagicMock, patch, AsyncMock, call import asyncio -from composer.events import EventBus, EventType, StackProcessedEvent, TwinUpdateEvent -from composer.subsystems.digital_twin_integration import ( - TwinServiceClient, TwinSynchronizer, DigitalTwinIntegration +import unittest +from unittest.mock import AsyncMock, MagicMock + +from muto_composer.events import EventBus, EventType, StackProcessedEvent, TwinUpdateEvent +from muto_composer.subsystems.digital_twin_integration import ( + DigitalTwinIntegration, + TwinServiceClient, + TwinSynchronizer, ) class TestTwinServiceClient(unittest.TestCase): - def setUp(self): self.logger = MagicMock() self.mock_node = MagicMock() self.event_bus = EventBus() self.client = TwinServiceClient(self.mock_node, self.event_bus, self.logger) - + # Mock the ROS service client self.mock_service_client = MagicMock() self.client.service_client = self.mock_service_client - + def test_initialization(self): """Test TwinServiceClient initialization.""" self.assertIsNotNone(self.client.logger) - self.assertTrue(hasattr(self.client, 'service_client')) - + self.assertTrue(hasattr(self.client, "service_client")) + def test_update_twin_state_success(self): """Test successful twin state update.""" # Mock successful service response mock_response = MagicMock() mock_response.success = True mock_response.message = "Update successful" - + # Mock the async method to return the response directly self.client.update_twin_state = MagicMock(return_value=True) - + twin_data = { "twin_id": "test_twin_001", - "properties": { - "deployment_status": "running", - "stack_name": "test_stack" - } + "properties": {"deployment_status": "running", "stack_name": "test_stack"}, } - + result = self.client.update_twin_state("test_twin_001", twin_data) - + self.assertTrue(result) self.client.update_twin_state.assert_called_once_with("test_twin_001", twin_data) - + def test_update_twin_state_failure(self): """Test failed twin state update.""" # Mock the async method to return failure self.client.update_twin_state = MagicMock(return_value=False) - + twin_data = {"twin_id": "test_twin_001"} - + result = self.client.update_twin_state("test_twin_001", twin_data) - + self.assertFalse(result) self.client.update_twin_state.assert_called_once_with("test_twin_001", twin_data) - + def test_update_twin_state_exception(self): """Test twin state update with exception.""" # Mock the async method to raise exception and handle it self.client.update_twin_state = MagicMock(return_value=False) - + twin_data = {"twin_id": "test_twin_001"} - + result = self.client.update_twin_state("test_twin_001", twin_data) - + self.assertFalse(result) self.client.update_twin_state.assert_called_once_with("test_twin_001", twin_data) - + def test_get_twin_state_success(self): """Test successful twin state retrieval.""" # Mock the async method to return success data - self.client.get_twin_state = MagicMock(return_value={"twin_id": "test_twin_001", "status": "active"}) - + self.client.get_twin_state = MagicMock( + return_value={"twin_id": "test_twin_001", "status": "active"} + ) + result = self.client.get_twin_state("test_twin_001") - + self.assertIsNotNone(result) self.assertEqual(result["twin_id"], "test_twin_001") self.assertEqual(result["status"], "active") self.client.get_twin_state.assert_called_once_with("test_twin_001") - + def test_get_twin_state_not_found(self): """Test twin state retrieval when twin not found.""" # Mock the async method to return None for not found self.client.get_twin_state = MagicMock(return_value=None) - + result = self.client.get_twin_state("nonexistent_twin") - + self.assertIsNone(result) self.client.get_twin_state.assert_called_once_with("nonexistent_twin") class TestTwinSynchronizer(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() - + # Mock the twin service client self.mock_twin_client = MagicMock() self.mock_twin_client.update_twin_state = AsyncMock(return_value=True) self.mock_twin_client.get_twin_state = AsyncMock(return_value={"status": "active"}) - + self.synchronizer = TwinSynchronizer(self.event_bus, self.mock_twin_client, self.logger) - + def test_initialization(self): """Test TwinSynchronizer initialization.""" self.assertIsNotNone(self.synchronizer.event_bus) self.assertIsNotNone(self.synchronizer.twin_client) self.assertIsNotNone(self.synchronizer.logger) - + def test_event_subscription(self): """Test that synchronizer subscribes to relevant events.""" # Verify that the synchronizer has event handlers set up # This would be implementation-specific based on how events are subscribed - self.assertTrue(hasattr(self.synchronizer, 'handle_stack_processed')) - self.assertTrue(hasattr(self.synchronizer, 'handle_deployment_status')) - + self.assertTrue(hasattr(self.synchronizer, "handle_stack_processed")) + self.assertTrue(hasattr(self.synchronizer, "handle_deployment_status")) + def test_handle_stack_processed_event(self): """Test handling of stack processed events.""" # Create a stack processed event - event = StackProcessedEvent( + StackProcessedEvent( stack_name="test_stack", - stack_payload={"nodes": ["node1"], "metadata": {"name": "test_stack"}}, # Updated parameter name - execution_requirements={"runtime": "docker"} + stack_payload={ + "nodes": ["node1"], + "metadata": {"name": "test_stack"}, + }, # Updated parameter name + execution_requirements={"runtime": "docker"}, ) - + # Mock the async method to be synchronous for testing self.synchronizer.sync_stack_state_to_twin = MagicMock(return_value=True) - + # Handle the event (call sync version for testing) try: # Since we made sync_stack_state_to_twin sync in our mock, this works self.assertTrue(True) # Test passes if no exception except Exception as e: self.fail(f"handle_stack_processed raised an exception: {e}") - + def test_handle_deployment_status_event(self): """Test handling of deployment status events.""" # Create a twin update event (simulating deployment status change) - event = TwinUpdateEvent( + TwinUpdateEvent( twin_id="test_twin_001", update_type="deployment_status", - data={"status": "deployed", "stack_name": "test_stack"} + data={"status": "deployed", "stack_name": "test_stack"}, ) - + # Mock the sync method self.synchronizer.sync_stack_state_to_twin = MagicMock(return_value=True) - + # Handle the event (simplified for testing) try: self.assertTrue(True) # Test passes if no exception except Exception as e: self.fail(f"handle_deployment_status raised an exception: {e}") - + def test_sync_stack_state_to_twin(self): """Test synchronizing stack state to digital twin.""" stack_data = { "stack_name": "test_stack", "deployment_status": "running", "nodes": ["node1", "node2"], - "timestamp": "2024-01-01T12:00:00Z" + "timestamp": "2024-01-01T12:00:00Z", } - + # Mock the method to return success self.synchronizer.sync_stack_state_to_twin = MagicMock(return_value=True) - + result = self.synchronizer.sync_stack_state_to_twin("test_twin_001", stack_data) - + self.assertTrue(result) self.synchronizer.sync_stack_state_to_twin.assert_called_with("test_twin_001", stack_data) - + def test_sync_stack_state_failure(self): """Test handling of sync failures.""" # Mock client to return failure self.synchronizer.sync_stack_state_to_twin = MagicMock(return_value=False) - + stack_data = {"stack_name": "test_stack"} - + result = self.synchronizer.sync_stack_state_to_twin("test_twin_001", stack_data) - + self.assertFalse(result) # Note: logger.warning check removed since we're mocking the method - + def test_extract_twin_data_from_stack(self): """Test extraction of twin-relevant data from stack.""" stack_payload = { - "metadata": { - "name": "test_stack", - "twin_id": "test_twin_001" - }, + "metadata": {"name": "test_stack", "twin_id": "test_twin_001"}, "nodes": ["node1"], - "launch": {"param1": "value1"} + "launch": {"param1": "value1"}, } - + twin_data = self.synchronizer._extract_twin_data_from_stack(stack_payload) - + self.assertIn("stack_name", twin_data) self.assertIn("twin_id", twin_data) self.assertIn("nodes", twin_data) @@ -220,106 +220,98 @@ def test_extract_twin_data_from_stack(self): class TestDigitalTwinIntegration(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() - + # Mock dependencies self.mock_node = MagicMock() self.mock_node.get_logger.return_value = self.logger - + self.integration = DigitalTwinIntegration(self.mock_node, self.event_bus, self.logger) - + def test_initialization(self): """Test DigitalTwinIntegration initialization.""" self.assertIsNotNone(self.integration.twin_client) self.assertIsNotNone(self.integration.synchronizer) self.assertIsNotNone(self.integration.event_bus) - + def test_get_components(self): """Test getting individual components.""" client = self.integration.get_twin_client() synchronizer = self.integration.get_synchronizer() - + self.assertIsNotNone(client) self.assertIsNotNone(synchronizer) - + def test_enable_disable_integration(self): """Test enabling and disabling integration.""" # Test enabling self.integration.enable() # Would verify that event subscriptions are active - + # Test disabling self.integration.disable() # Would verify that event subscriptions are removed - + # For now, just verify methods exist and don't raise errors - self.assertTrue(hasattr(self.integration, 'enable')) - self.assertTrue(hasattr(self.integration, 'disable')) - + self.assertTrue(hasattr(self.integration, "enable")) + self.assertTrue(hasattr(self.integration, "disable")) + def test_integration_with_stack_processing(self): """Test integration with stack processing events.""" # Setup event capture to verify twin updates twin_updates = [] - + def capture_twin_update(event): twin_updates.append(event) - + self.event_bus.subscribe(EventType.TWIN_UPDATE, capture_twin_update) - + # Simulate a stack processed event stack_event = StackProcessedEvent( stack_name="integration_test_stack", stack_payload={ # Updated parameter name "metadata": {"name": "integration_test_stack", "twin_id": "twin_001"}, - "nodes": ["node1"] + "nodes": ["node1"], }, - execution_requirements={"runtime": "docker"} + execution_requirements={"runtime": "docker"}, ) - + # Publish the event to trigger twin integration self.event_bus.publish_sync(stack_event) - + # In a real implementation, this would trigger async operations # For testing, we verify the integration components are properly set up self.assertIsNotNone(self.integration.synchronizer) - + def test_twin_id_extraction(self): """Test extraction of twin ID from stack metadata.""" # Test stack with explicit twin_id - stack_with_twin_id = { - "metadata": { - "name": "test_stack", - "twin_id": "explicit_twin_001" - } - } - + stack_with_twin_id = {"metadata": {"name": "test_stack", "twin_id": "explicit_twin_001"}} + twin_id = self.integration._extract_twin_id(stack_with_twin_id) self.assertEqual(twin_id, "explicit_twin_001") - + # Test stack without twin_id (should use stack name) - stack_without_twin_id = { - "metadata": { - "name": "test_stack_no_twin" - } - } - + stack_without_twin_id = {"metadata": {"name": "test_stack_no_twin"}} + twin_id = self.integration._extract_twin_id(stack_without_twin_id) self.assertEqual(twin_id, "test_stack_no_twin") - + def test_integration_error_handling(self): """Test error handling in integration.""" # Mock components to raise exceptions - self.integration.twin_client.update_twin_state = AsyncMock(side_effect=Exception("Service error")) - + self.integration.twin_client.update_twin_state = AsyncMock( + side_effect=Exception("Service error") + ) + # Verify that exceptions are handled gracefully # This would be tested by triggering events and verifying logs self.assertIsNotNone(self.integration.logger) -if __name__ == '__main__': +if __name__ == "__main__": # For async tests, we need to run them properly def run_async_test(coro): """Helper to run async tests.""" @@ -329,5 +321,5 @@ def run_async_test(coro): loop.run_until_complete(coro) finally: loop.close() - - unittest.main() \ No newline at end of file + + unittest.main() diff --git a/test/test_ditto_handler_stack_model.py b/test/test_ditto_handler_stack_model.py index 98483b4..6632296 100644 --- a/test/test_ditto_handler_stack_model.py +++ b/test/test_ditto_handler_stack_model.py @@ -20,8 +20,8 @@ import unittest from unittest.mock import MagicMock, patch -from composer.stack_handlers.ditto_handler import DittoStackHandler -from composer.plugins.base_plugin import StackContext, StackOperation +from muto_composer.plugins.base_plugin import StackContext, StackOperation +from muto_composer.stack_handlers.ditto_handler import DittoStackHandler class TestDittoStackHandlerCanHandle(unittest.TestCase): @@ -35,7 +35,7 @@ def test_can_handle_node_based_manifest(self): payload = { "node": [ {"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"}, - {"name": "listener", "pkg": "demo_nodes_cpp", "exec": "listener"} + {"name": "listener", "pkg": "demo_nodes_cpp", "exec": "listener"}, ] } self.assertTrue(self.handler.can_handle(payload)) @@ -48,9 +48,7 @@ def test_can_handle_composable_based_manifest(self): "name": "container", "pkg": "rclcpp_components", "exec": "component_container", - "nodes": [ - {"name": "talker", "plugin": "demo_nodes_cpp::Talker"} - ] + "nodes": [{"name": "talker", "plugin": "demo_nodes_cpp::Talker"}], } ] } @@ -58,33 +56,24 @@ def test_can_handle_composable_based_manifest(self): def test_can_handle_script_based_manifest(self): """Test that handler recognizes script-based legacy manifests.""" - payload = { - "on_start": "/opt/muto/start.sh", - "on_kill": "/opt/muto/stop.sh" - } + payload = {"on_start": "/opt/muto/start.sh", "on_kill": "/opt/muto/stop.sh"} self.assertTrue(self.handler.can_handle(payload)) def test_can_handle_launch_description_source(self): """Test that handler recognizes launch_description_source manifests.""" - payload = { - "launch_description_source": "/opt/ros/launch/demo.launch.py" - } + payload = {"launch_description_source": "/opt/ros/launch/demo.launch.py"} self.assertTrue(self.handler.can_handle(payload)) def test_can_handle_launch_nested_structure(self): """Test that handler recognizes nested launch structures.""" - payload = { - "launch": { - "node": [{"name": "test", "pkg": "pkg", "exec": "exec"}] - } - } + payload = {"launch": {"node": [{"name": "test", "pkg": "pkg", "exec": "exec"}]}} self.assertTrue(self.handler.can_handle(payload)) def test_cannot_handle_new_format_stack_archive(self): """Test that handler rejects properly typed stack/archive payloads.""" payload = { "metadata": {"content_type": "stack/archive"}, - "launch": {"data": "base64encoded"} + "launch": {"data": "base64encoded"}, } self.assertFalse(self.handler.can_handle(payload)) @@ -111,20 +100,16 @@ def _create_context(self, stack_data, operation): logger=MagicMock(), workspace_path="/tmp/muto/test", launcher=MagicMock(), - hash="testhash" + hash="testhash", ) - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_start_ditto_instantiates_stack_and_launches(self, mock_stack_class): """Test that _start_ditto creates Stack and calls launch().""" mock_stack = MagicMock() mock_stack_class.return_value = mock_stack - payload = { - "node": [ - {"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"} - ] - } + payload = {"node": [{"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"}]} context = self._create_context(payload, StackOperation.START) result = self.handler._start_ditto(context) @@ -133,7 +118,7 @@ def test_start_ditto_instantiates_stack_and_launches(self, mock_stack_class): mock_stack_class.assert_called_once_with(manifest=payload) mock_stack.launch.assert_called_once_with(context.launcher) - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_start_ditto_with_nested_launch_structure(self, mock_stack_class): """Test that _start_ditto handles nested launch structure.""" mock_stack = MagicMock() @@ -149,17 +134,13 @@ def test_start_ditto_with_nested_launch_structure(self, mock_stack_class): mock_stack_class.assert_called_once_with(manifest=launch_data) mock_stack.launch.assert_called_once() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_kill_ditto_instantiates_stack_and_kills(self, mock_stack_class): """Test that _kill_ditto creates Stack and calls kill().""" mock_stack = MagicMock() mock_stack_class.return_value = mock_stack - payload = { - "node": [ - {"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"} - ] - } + payload = {"node": [{"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"}]} context = self._create_context(payload, StackOperation.KILL) result = self.handler._kill_ditto(context) @@ -168,17 +149,13 @@ def test_kill_ditto_instantiates_stack_and_kills(self, mock_stack_class): mock_stack_class.assert_called_once_with(manifest=payload) mock_stack.kill.assert_called_once() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_apply_ditto_instantiates_stack_and_applies(self, mock_stack_class): """Test that _apply_ditto creates Stack and calls apply().""" mock_stack = MagicMock() mock_stack_class.return_value = mock_stack - payload = { - "node": [ - {"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"} - ] - } + payload = {"node": [{"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"}]} context = self._create_context(payload, StackOperation.APPLY) result = self.handler._apply_ditto(context) @@ -189,10 +166,7 @@ def test_apply_ditto_instantiates_stack_and_applies(self, mock_stack_class): def test_start_script_based_ditto_delegates_to_plugin(self): """Test that script-based stacks are delegated (not using Stack model).""" - payload = { - "on_start": "/opt/muto/start.sh", - "on_kill": "/opt/muto/stop.sh" - } + payload = {"on_start": "/opt/muto/start.sh", "on_kill": "/opt/muto/stop.sh"} context = self._create_context(payload, StackOperation.START) result = self.handler._start_ditto(context) @@ -202,10 +176,7 @@ def test_start_script_based_ditto_delegates_to_plugin(self): def test_kill_script_based_ditto_delegates_to_plugin(self): """Test that script-based stack kill is delegated.""" - payload = { - "on_start": "/opt/muto/start.sh", - "on_kill": "/opt/muto/stop.sh" - } + payload = {"on_start": "/opt/muto/start.sh", "on_kill": "/opt/muto/stop.sh"} context = self._create_context(payload, StackOperation.KILL) result = self.handler._kill_ditto(context) @@ -214,10 +185,7 @@ def test_kill_script_based_ditto_delegates_to_plugin(self): def test_apply_script_based_ditto_is_noop(self): """Test that apply for script-based stacks is a no-op.""" - payload = { - "on_start": "/opt/muto/start.sh", - "on_kill": "/opt/muto/stop.sh" - } + payload = {"on_start": "/opt/muto/start.sh", "on_kill": "/opt/muto/stop.sh"} context = self._create_context(payload, StackOperation.APPLY) result = self.handler._apply_ditto(context) @@ -245,7 +213,7 @@ def _create_context(self, stack_data, operation): logger=MagicMock(), workspace_path="/tmp/muto/test", launcher=MagicMock(), - hash="testhash" + hash="testhash", ) def test_provision_operation_returns_true(self): @@ -258,7 +226,7 @@ def test_provision_operation_returns_true(self): self.assertTrue(result) - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_start_operation_delegates_to_start_ditto(self, mock_stack_class): """Test that START operation calls _start_ditto.""" mock_stack = MagicMock() @@ -274,7 +242,7 @@ def test_start_operation_delegates_to_start_ditto(self, mock_stack_class): self.assertTrue(result) mock_stack.launch.assert_called_once() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_kill_operation_delegates_to_kill_ditto(self, mock_stack_class): """Test that KILL operation calls _kill_ditto.""" mock_stack = MagicMock() @@ -290,7 +258,7 @@ def test_kill_operation_delegates_to_kill_ditto(self, mock_stack_class): self.assertTrue(result) mock_stack.kill.assert_called_once() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_apply_operation_delegates_to_apply_ditto(self, mock_stack_class): """Test that APPLY operation calls _apply_ditto.""" mock_stack = MagicMock() @@ -332,10 +300,10 @@ def _create_context(self, stack_data, operation): logger=MagicMock(), workspace_path="/tmp/muto/test", launcher=MagicMock(), - hash="testhash" + hash="testhash", ) - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_start_handles_stack_exception(self, mock_stack_class): """Test that exceptions during start are handled gracefully.""" mock_stack_class.side_effect = Exception("Stack creation failed") @@ -348,7 +316,7 @@ def test_start_handles_stack_exception(self, mock_stack_class): self.assertFalse(result) self.handler.logger.error.assert_called() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_kill_handles_stack_exception(self, mock_stack_class): """Test that exceptions during kill are handled gracefully.""" mock_stack_class.side_effect = Exception("Stack creation failed") @@ -361,7 +329,7 @@ def test_kill_handles_stack_exception(self, mock_stack_class): self.assertFalse(result) self.handler.logger.error.assert_called() - @patch('composer.stack_handlers.ditto_handler.Stack') + @patch("muto_composer.stack_handlers.ditto_handler.Stack") def test_apply_handles_stack_exception(self, mock_stack_class): """Test that exceptions during apply are handled gracefully.""" mock_stack_class.side_effect = Exception("Stack creation failed") diff --git a/test/test_events.py b/test/test_events.py index 475885b..c43c1df 100644 --- a/test/test_events.py +++ b/test/test_events.py @@ -12,103 +12,106 @@ # import unittest -from unittest.mock import MagicMock, patch -from composer.events import ( - EventBus, EventType, BaseComposeEvent, StackRequestEvent, - StackAnalyzedEvent, OrchestrationStartedEvent +from unittest.mock import MagicMock + +from muto_composer.events import ( + EventBus, + EventType, + OrchestrationStartedEvent, + StackAnalyzedEvent, + StackRequestEvent, ) class TestEventBus(unittest.TestCase): - def setUp(self): self.event_bus = EventBus(max_workers=2) self.test_handler = MagicMock() - + def test_subscribe_and_publish_sync(self): """Test synchronous event publishing.""" # Subscribe to event self.event_bus.subscribe(EventType.STACK_REQUEST, self.test_handler) - + # Create and publish event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Verify handler was called self.test_handler.assert_called_once_with(event) - + def test_multiple_handlers(self): """Test multiple handlers for same event type.""" handler2 = MagicMock() - + # Subscribe multiple handlers self.event_bus.subscribe(EventType.STACK_REQUEST, self.test_handler) self.event_bus.subscribe(EventType.STACK_REQUEST, handler2) - + # Publish event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Verify both handlers were called self.test_handler.assert_called_once_with(event) handler2.assert_called_once_with(event) - + def test_unsubscribe(self): """Test unsubscribing from events.""" # Subscribe and then unsubscribe self.event_bus.subscribe(EventType.STACK_REQUEST, self.test_handler) self.event_bus.unsubscribe(EventType.STACK_REQUEST, self.test_handler) - + # Publish event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Verify handler was not called self.test_handler.assert_not_called() - + def test_handler_exception_handling(self): """Test that exceptions in one handler don't affect others.""" failing_handler = MagicMock(side_effect=Exception("Handler error")) - + # Subscribe both handlers self.event_bus.subscribe(EventType.STACK_REQUEST, failing_handler) self.event_bus.subscribe(EventType.STACK_REQUEST, self.test_handler) - + # Set up logger mock to verify error logging logger_mock = MagicMock() self.event_bus._logger = logger_mock - + # Publish event event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", - action="start" + action="start", ) - + self.event_bus.publish_sync(event) - + # Verify failing handler was called failing_handler.assert_called_once() - + # The second handler should still be called despite the first one failing # However, current implementation might stop on first exception # For now, just verify error handling doesn't crash @@ -116,7 +119,6 @@ def test_handler_exception_handling(self): class TestEventClasses(unittest.TestCase): - def test_stack_request_event_creation(self): """Test StackRequestEvent creation and attributes.""" event = StackRequestEvent( @@ -124,9 +126,9 @@ def test_stack_request_event_creation(self): source_component="test_component", stack_name="test_stack", action="start", - stack_payload={"key": "value"} + stack_payload={"key": "value"}, ) - + self.assertEqual(event.event_type, EventType.STACK_REQUEST) self.assertEqual(event.source_component, "test_component") self.assertEqual(event.stack_name, "test_stack") @@ -134,7 +136,7 @@ def test_stack_request_event_creation(self): self.assertEqual(event.stack_payload["key"], "value") self.assertIsNotNone(event.event_id) self.assertIsNotNone(event.timestamp) - + def test_stack_analyzed_event_creation(self): """Test StackAnalyzedEvent creation.""" event = StackAnalyzedEvent( @@ -143,15 +145,15 @@ def test_stack_analyzed_event_creation(self): stack_name="test_stack", action="start", analysis_result={"stack_type": "json"}, - processing_requirements={"merge_manifests": True} + processing_requirements={"merge_manifests": True}, ) - + self.assertEqual(event.event_type, EventType.STACK_ANALYZED) self.assertEqual(event.stack_name, "test_stack") self.assertEqual(event.action, "start") self.assertEqual(event.analysis_result["stack_type"], "json") self.assertTrue(event.processing_requirements["merge_manifests"]) - + def test_orchestration_started_event_creation(self): """Test OrchestrationStartedEvent creation.""" event = OrchestrationStartedEvent( @@ -159,9 +161,9 @@ def test_orchestration_started_event_creation(self): source_component="orchestrator", action="start", execution_plan={"pipeline_name": "start"}, - context_variables={"should_run_provision": True} + context_variables={"should_run_provision": True}, ) - + self.assertEqual(event.event_type, EventType.ORCHESTRATION_STARTED) self.assertEqual(event.action, "start") self.assertEqual(event.execution_plan["pipeline_name"], "start") @@ -169,5 +171,5 @@ def test_orchestration_started_event_creation(self): self.assertIsNotNone(event.orchestration_id) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/test/test_integration_stack_handlers.py b/test/test_integration_stack_handlers.py index 308540f..d618260 100644 --- a/test/test_integration_stack_handlers.py +++ b/test/test_integration_stack_handlers.py @@ -21,12 +21,13 @@ import json import unittest from unittest.mock import MagicMock, patch + import rclpy -from composer.plugins.launch_plugin import MutoDefaultLaunchPlugin -from composer.plugins.provision_plugin import MutoProvisionPlugin -from composer.plugins.compose_plugin import MutoDefaultComposePlugin -from composer.stack_handlers.registry import StackTypeRegistry +from muto_composer.plugins.compose_plugin import MutoDefaultComposePlugin +from muto_composer.plugins.launch_plugin import MutoDefaultLaunchPlugin +from muto_composer.plugins.provision_plugin import MutoProvisionPlugin +from muto_composer.stack_handlers.registry import StackTypeRegistry class TestStackHandlerIntegration(unittest.TestCase): @@ -45,15 +46,15 @@ def test_json_stack_handler_selection(self): mock_node = MagicMock() mock_logger = MagicMock() mock_node.get_logger.return_value = mock_logger - + registry = StackTypeRegistry(mock_node, mock_logger) registry.discover_and_register_handlers() - + payload = { "metadata": {"content_type": "stack/json", "name": "Test"}, - "launch": {"node": [{"name": "test"}]} + "launch": {"node": [{"name": "test"}]}, } - + handler = registry.get_handler(payload) self.assertIsNotNone(handler) self.assertEqual(handler.__class__.__name__, "JsonStackHandler") @@ -63,15 +64,15 @@ def test_archive_stack_handler_selection(self): mock_node = MagicMock() mock_logger = MagicMock() mock_node.get_logger.return_value = mock_logger - + registry = StackTypeRegistry(mock_node, mock_logger) registry.discover_and_register_handlers() - + payload = { "metadata": {"content_type": "stack/archive", "name": "Test"}, - "launch": {"archive": "base64data"} + "launch": {"archive": "base64data"}, } - + handler = registry.get_handler(payload) self.assertIsNotNone(handler) self.assertEqual(handler.__class__.__name__, "ArchiveStackHandler") @@ -81,171 +82,169 @@ def test_ditto_stack_handler_selection(self): mock_node = MagicMock() mock_logger = MagicMock() mock_node.get_logger.return_value = mock_logger - + registry = StackTypeRegistry(mock_node, mock_logger) registry.discover_and_register_handlers() - - payload = { - "node": [{"name": "test_node", "pkg": "test_pkg", "exec": "test_exec"}] - } - + + payload = {"node": [{"name": "test_node", "pkg": "test_pkg", "exec": "test_exec"}]} + handler = registry.get_handler(payload) self.assertIsNotNone(handler) self.assertEqual(handler.__class__.__name__, "DittoStackHandler") - @patch('composer.stack_handlers.json_handler.Stack') + @patch("muto_composer.stack_handlers.json_handler.Stack") def test_launch_plugin_json_stack_integration(self, mock_stack): """Integration test: Launch plugin with JSON stack.""" plugin = MutoDefaultLaunchPlugin() - + stack_data = { "metadata": {"content_type": "stack/json", "name": "Integration Test"}, - "launch": {"node": [{"name": "test_node", "pkg": "demo_nodes_cpp", "exec": "talker"}]} + "launch": {"node": [{"name": "test_node", "pkg": "demo_nodes_cpp", "exec": "talker"}]}, } - + request = MagicMock() request.input.current.stack = json.dumps(stack_data) request.input.current.source = "{}" response = MagicMock() response.success = False response.err_msg = "" - + plugin.handle_start(request, response) - + # Test outcome: verify success self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - + plugin.destroy_node() def test_launch_plugin_archive_stack_integration(self): """Integration test: Launch plugin with archive stack.""" plugin = MutoDefaultLaunchPlugin() - + stack_data = { "metadata": {"content_type": "stack/archive", "name": "Archive Test"}, - "launch": {"properties": {"launch_file": "test.launch.py"}} + "launch": {"properties": {"launch_file": "test.launch.py"}}, } - + request = MagicMock() request.input.current.stack = json.dumps(stack_data) request.input.current.source = "{}" response = MagicMock() response.success = False response.err_msg = "" - + plugin.handle_start(request, response) - + # Test outcome: verify success self.assertTrue(response.success) - + plugin.destroy_node() def test_provision_plugin_no_stack_handling(self): """Integration test: Provision plugin handles missing stack gracefully.""" plugin = MutoProvisionPlugin() - + request = MagicMock() request.input.current.stack = "" request.start = True response = MagicMock() response.success = True response.err_msg = "" - + plugin.handle_provision(request, response) - + # Test outcome: verify failure with appropriate message self.assertFalse(response.success) self.assertIn("No current stack", response.err_msg) - + plugin.destroy_node() def test_provision_plugin_json_stack_integration(self): """Integration test: Provision plugin with JSON stack.""" plugin = MutoProvisionPlugin() - + stack_data = { "metadata": {"content_type": "stack/json", "name": "Provision Test"}, - "launch": {"node": []} + "launch": {"node": []}, } - + request = MagicMock() request.input.current.stack = json.dumps(stack_data) request.start = True response = MagicMock() response.success = False response.err_msg = "" - + plugin.handle_provision(request, response) - + # Test outcome: verify it processes (success depends on mocked dependencies) # The important thing is it doesn't crash and sets response appropriately self.assertIsNotNone(response.success) - + plugin.destroy_node() def test_compose_plugin_integration(self): """Integration test: Compose plugin with valid stack.""" plugin = MutoDefaultComposePlugin() - + stack_data = { "metadata": {"content_type": "stack/json", "name": "Compose Test"}, - "launch": {"node": [{"name": "test"}]} + "launch": {"node": [{"name": "test"}]}, } - + request = MagicMock() request.input.current.stack = json.dumps(stack_data) response = MagicMock() response.success = False response.err_msg = "" - + plugin.handle_compose(request, response) - + # Test outcome: verify success self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - + plugin.destroy_node() def test_handler_exception_handling(self): """Integration test: Verify exception handling in plugin.""" plugin = MutoDefaultLaunchPlugin() - + # Invalid JSON should be handled gracefully request = MagicMock() request.input.current.stack = "invalid json" response = MagicMock() response.success = True response.err_msg = "" - + plugin.handle_start(request, response) - + # Test outcome: should fail gracefully self.assertFalse(response.success) - + plugin.destroy_node() def test_unknown_content_type_handling(self): """Integration test: Unknown content_type handled gracefully.""" plugin = MutoDefaultLaunchPlugin() - + stack_data = { "metadata": {"content_type": "stack/unknown", "name": "Unknown Test"}, - "launch": {} + "launch": {}, } - + request = MagicMock() request.input.current.stack = json.dumps(stack_data) request.input.current.source = "{}" response = MagicMock() response.success = True response.err_msg = "" - + plugin.handle_start(request, response) - + # Test outcome: should fail when no handler found self.assertFalse(response.success) - + plugin.destroy_node() @@ -260,17 +259,17 @@ def setUpClass(cls): def tearDownClass(cls): rclpy.shutdown() - @patch('composer.stack_handlers.json_handler.Stack') + @patch("muto_composer.stack_handlers.json_handler.Stack") def test_json_stack_lifecycle(self, mock_stack): """Test complete lifecycle of a JSON stack.""" launch_plugin = MutoDefaultLaunchPlugin() - + stack_data = { "metadata": {"content_type": "stack/json", "name": "Lifecycle Test"}, - "launch": {"node": [{"name": "test_node", "pkg": "demo_nodes_cpp", "exec": "talker"}]} + "launch": {"node": [{"name": "test_node", "pkg": "demo_nodes_cpp", "exec": "talker"}]}, } stack_json = json.dumps(stack_data) - + # 1. START request = MagicMock() request.input.current.stack = stack_json @@ -278,20 +277,20 @@ def test_json_stack_lifecycle(self, mock_stack): response = MagicMock() response.success = False response.err_msg = "" - + launch_plugin.handle_start(request, response) self.assertTrue(response.success, "Start should succeed") - + # 2. KILL request = MagicMock() request.input.current.stack = stack_json response = MagicMock() response.success = False response.err_msg = "" - + launch_plugin.handle_kill(request, response) self.assertTrue(response.success, "Kill should succeed") - + # 3. APPLY request = MagicMock() request.input.current.stack = stack_json @@ -299,12 +298,12 @@ def test_json_stack_lifecycle(self, mock_stack): response = MagicMock() response.success = False response.err_msg = "" - + launch_plugin.handle_apply(request, response) self.assertTrue(response.success, "Apply should succeed") - + launch_plugin.destroy_node() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/test/test_introspector.py b/test/test_introspector.py index 43eb6f2..a784657 100644 --- a/test/test_introspector.py +++ b/test/test_introspector.py @@ -11,10 +11,11 @@ # Composiv.ai - initial API and implementation # -import unittest -from unittest.mock import patch, call import subprocess -from composer.introspection.introspector import Introspector +import unittest +from unittest.mock import call, patch + +from muto_composer.introspection.introspector import Introspector class TestIntrospector(unittest.TestCase): diff --git a/test/test_launch_plugin.py b/test/test_launch_plugin.py index bfa69f5..13c5a99 100644 --- a/test/test_launch_plugin.py +++ b/test/test_launch_plugin.py @@ -16,16 +16,13 @@ import subprocess import unittest from unittest.mock import MagicMock, patch -from composer.stack_handlers import StackTypeRegistry import rclpy -from composer.plugins.launch_plugin import MutoDefaultLaunchPlugin -from muto_msgs.srv import LaunchPlugin +from muto_composer.plugins.launch_plugin import MutoDefaultLaunchPlugin class TestLaunchPlugin(unittest.TestCase): - def setUp(self) -> None: self.node = MutoDefaultLaunchPlugin() self.node.async_loop = MagicMock() @@ -33,32 +30,32 @@ def setUp(self) -> None: # Mock the stack parser self.node.stack_parser = MagicMock() # Mock the launcher.kill method (launcher is now initialized in BasePlugin.__init__) - if hasattr(self.node, 'launcher') and self.node.launcher: + if hasattr(self.node, "launcher") and self.node.launcher: self.node.launcher.kill = MagicMock() - + # Set up default mock handler for double dispatch - tests can override if needed self.default_mock_handler = MagicMock() self.default_mock_handler.apply_to_plugin = MagicMock(return_value=True) self.node.stack_registry.get_handler = MagicMock(return_value=self.default_mock_handler) - + # Set up global Stack mocking for handlers - self.stack_patcher_json = patch('composer.stack_handlers.json_handler.Stack') - self.stack_patcher_ditto = patch('composer.stack_handlers.ditto_handler.Stack') + self.stack_patcher_json = patch("muto_composer.stack_handlers.json_handler.Stack") + self.stack_patcher_ditto = patch("muto_composer.stack_handlers.ditto_handler.Stack") self.mock_stack_json = self.stack_patcher_json.start() self.mock_stack_ditto = self.stack_patcher_ditto.start() - + # Configure mock Stack instances self.mock_stack_instance = MagicMock() self.mock_stack_json.return_value = self.mock_stack_instance self.mock_stack_ditto.return_value = self.mock_stack_instance - + # Don't set up mock current_stack - let the methods parse it from requests - + def tearDown(self) -> None: """Clean up patches.""" self.stack_patcher_json.stop() self.stack_patcher_ditto.stop() - + def _create_mock_handler(self, returns_success=True, should_raise=None): """Helper to create a mock handler that simulates double dispatch.""" mock_handler = MagicMock() @@ -67,7 +64,7 @@ def _create_mock_handler(self, returns_success=True, should_raise=None): else: mock_handler.apply_to_plugin = MagicMock(return_value=returns_success) return mock_handler - + def _mock_handler_for_start(self): """Mock the stack registry to return a successful handler for start operations.""" mock_handler = self._create_mock_handler(returns_success=True) @@ -85,9 +82,9 @@ def setUpClass(cls) -> None: def tearDownClass(cls) -> None: rclpy.shutdown() - @patch("composer.plugins.launch_plugin.MutoDefaultLaunchPlugin.source_workspaces") + @patch("muto_composer.plugins.launch_plugin.MutoDefaultLaunchPlugin.source_workspaces") @patch("os.chdir") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_exception(self, mock_launch_plugin, mock_os, mock_ws): # Create proper mock request and response objects request = MagicMock() @@ -96,7 +93,7 @@ def test_handle_start_exception(self, mock_launch_plugin, mock_os, mock_ws): response.err_msg = "" request.start = None # This will cause the "Start flag not set" error request.input.current.stack = None - + self.node.handle_start(request, response) mock_os.assert_not_called() mock_ws.assert_not_called() @@ -150,28 +147,27 @@ def test_find_file_not_found(self, mock_join, mock_isfile, mock_walk): @patch("subprocess.run") @patch("os.environ.update") - def test_source_workspaces_no_current_stack( - self, mock_environ_update, mock_subprocess_run - ): + def test_source_workspaces_no_current_stack(self, mock_environ_update, mock_subprocess_run): mock_current = None self.node.source_workspaces(mock_current) mock_subprocess_run.assert_not_called() mock_environ_update.assert_not_called() - @patch("composer.utils.paths.get_workspaces_path", return_value="/tmp/muto/muto_workspaces") + @patch( + "muto_composer.utils.paths.get_workspaces_path", return_value="/tmp/muto/muto_workspaces" + ) @patch("subprocess.run") @patch("os.environ.update") def test_source_workspace(self, mock_environ_update, mock_subprocess_run, mock_get_path): mock_current = MagicMock() mock_current.source = json.dumps({"workspace_name": "/mock/file"}) # Mock the _get_stack_name method - with patch.object(self.node, '_get_stack_name', return_value="Test Stack"): - with patch("composer.plugins.launch_plugin.WORKSPACES_PATH", "/tmp/muto/muto_workspaces"): - self.node.source_workspaces(mock_current) + with patch.object(self.node, "_get_stack_name", return_value="Test Stack"), patch( + "muto_composer.plugins.launch_plugin.WORKSPACES_PATH", "/tmp/muto/muto_workspaces" + ): + self.node.source_workspaces(mock_current) - self.node.get_logger().info.assert_called_with( - "Sourced workspace: workspace_name" - ) + self.node.get_logger().info.assert_called_with("Sourced workspace: workspace_name") mock_environ_update.assert_called_once_with({}) mock_subprocess_run.assert_called_once_with( "bash -c 'source /mock/file && env'", @@ -189,12 +185,10 @@ def test_subprocess_failure(self, mock_environ_update, mock_subprocess_run): mock_current = MagicMock() mock_current.source = json.dumps({"workspace_name": "/mock/file"}) - mock_subprocess_run.side_effect = subprocess.CalledProcessError( - returncode=1, cmd="" - ) + mock_subprocess_run.side_effect = subprocess.CalledProcessError(returncode=1, cmd="") # Mock the _get_stack_name method - with patch.object(self.node, '_get_stack_name', return_value="Test Stack"): + with patch.object(self.node, "_get_stack_name", return_value="Test Stack"): self.node.source_workspaces(mock_current) mock_environ_update.assert_not_called() @@ -204,14 +198,14 @@ def test_subprocess_exception(self, mock_environ_update, mock_subprocess_run): mock_current = MagicMock() mock_current.source = json.dumps({"workspace_name": "/mock/file"}) mock_subprocess_run.side_effect = Exception("Unexpected exception") - + # Mock the _get_stack_name method - with patch.object(self.node, '_get_stack_name', return_value="Test Stack"): + with patch.object(self.node, "_get_stack_name", return_value="Test Stack"): self.node.source_workspaces(mock_current) mock_environ_update.assert_not_called() - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_start_none(self, mock_launch_plugin): response = MagicMock() response.success = False @@ -221,7 +215,9 @@ def test_handle_start_start_none(self, mock_launch_plugin): request.input.current.stack = None # Mock handler that raises exception - self.default_mock_handler.apply_to_plugin = MagicMock(side_effect=Exception("Test exception")) + self.default_mock_handler.apply_to_plugin = MagicMock( + side_effect=Exception("Test exception") + ) returned_value = self.node.handle_start(request, response) @@ -233,12 +229,12 @@ def test_handle_start_start_none(self, mock_launch_plugin): self.assertFalse(response.success) self.assertEqual(response.err_msg, "No current stack available or start flag not set.") - @patch("composer.plugins.launch_plugin.subprocess.Popen") + @patch("muto_composer.plugins.launch_plugin.subprocess.Popen") @patch("os.chmod") @patch("builtins.open", create=True) @patch.object(MutoDefaultLaunchPlugin, "find_file") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start( self, mock_launch_plugin, @@ -253,18 +249,11 @@ def test_handle_start( response.err_msg = "" request = MagicMock() request.start = True - + # Use proper JSON structure matching new implementation stack_data = { - "metadata": { - "name": "Test Stack", - "content_type": "stack/archive" - }, - "launch": { - "properties": { - "launch_file": "test.launch.py" - } - } + "metadata": {"name": "Test Stack", "content_type": "stack/archive"}, + "launch": {"properties": {"launch_file": "test.launch.py"}}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -272,7 +261,7 @@ def test_handle_start( # Mock handler to return success - tests the integration through double dispatch mock_handler = self._create_mock_handler(returns_success=True) self.node.stack_registry.get_handler = MagicMock(return_value=mock_handler) - + self.node.launch_arguments = ["test:=test_args"] self.node.handle_start(request, response) @@ -283,7 +272,7 @@ def test_handle_start( @patch.object(MutoDefaultLaunchPlugin, "run_script") @patch.object(MutoDefaultLaunchPlugin, "find_file") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_none( self, mock_launch_plugin, mock_source_workspace, mock_find_file, mock_run_script ): @@ -296,19 +285,18 @@ def test_handle_start_none( # Mock: No handler found for this stack type self.node.stack_registry.get_handler = MagicMock(return_value=None) - # Use proper JSON structure with no recognized launch method stack_data = { "metadata": { "name": "No Launch Method Stack", - "description": "A stack without launch method" + "description": "A stack without launch method", } } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) - + # Mock no recognized payload type and no legacy methods - + self.node.launch_arguments = ["test:=test_args"] self.node.handle_start(request, response) @@ -365,8 +353,8 @@ def test_run_script_not_access(self, mock_chmod, mock_access, mock_path, mock_ru ["/mock/script/path"], check=True, capture_output=True, text=True ) - @patch("composer.plugins.launch_plugin.CoreTwin") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.CoreTwin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_start_none(self, mock_launch_plugin, mock_core_twin): request = MagicMock() request.start = None @@ -376,7 +364,6 @@ def test_handle_kill_start_none(self, mock_launch_plugin, mock_core_twin): response.err_msg = "" self.node.handle_kill(request, response) - # Expect 1 warning call: only one for start flag not set (no JSON parsing warning when stack is None) self.assertEqual(self.node.get_logger().warning.call_count, 1) self.assertFalse(response.success) @@ -384,7 +371,7 @@ def test_handle_kill_start_none(self, mock_launch_plugin, mock_core_twin): mock_core_twin.assert_not_called() @patch.object(MutoDefaultLaunchPlugin, "_terminate_launch_process") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill(self, mock_launch_plugin, mock_terminate): self.node.set_stack_cli = MagicMock() @@ -393,18 +380,11 @@ def test_handle_kill(self, mock_launch_plugin, mock_terminate): response = MagicMock() response.success = False response.err_msg = "" - + # Use proper JSON structure for archive stack stack_data = { - "metadata": { - "name": "test_stack", - "content_type": "stack/archive" - }, - "launch": { - "properties": { - "launch_file": "test_launch_file.launch.py" - } - } + "metadata": {"name": "test_stack", "content_type": "stack/archive"}, + "launch": {"properties": {"launch_file": "test_launch_file.launch.py"}}, } request.input.current.stack = json.dumps(stack_data) @@ -416,9 +396,9 @@ def test_handle_kill(self, mock_launch_plugin, mock_terminate): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_raw_stack(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): """Test handle_apply with raw stack payload.""" # Use real handlers for this test @@ -430,11 +410,10 @@ def test_handle_apply_raw_stack(self, mock_launch_plugin, mock_stack_json, mock_ response.success = False response.err_msg = "" - # Use proper JSON structure for raw stack stack_data = { "node": [{"name": "test_node", "pkg": "test_pkg"}], - "metadata": {"name": "test_stack"} + "metadata": {"name": "test_stack"}, } request.input.current.stack = json.dumps(stack_data) @@ -449,7 +428,7 @@ def test_handle_apply_raw_stack(self, mock_launch_plugin, mock_stack_json, mock_ self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_json_stack(self, mock_launch_plugin): """Test handle_apply with stack/json payload.""" # Use real handlers for this test @@ -461,11 +440,10 @@ def test_handle_apply_json_stack(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper JSON structure for stack/json stack_data = { "metadata": {"name": "test_stack", "content_type": "stack/json"}, - "launch": {"node": [{"name": "test_node"}]} + "launch": {"node": [{"name": "test_node"}]}, } request.input.current.stack = json.dumps(stack_data) @@ -476,7 +454,7 @@ def test_handle_apply_json_stack(self, mock_launch_plugin): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_archive_stack(self, mock_launch_plugin): """Test handle_apply with stack/archive payload.""" request = MagicMock() @@ -485,11 +463,10 @@ def test_handle_apply_archive_stack(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper JSON structure for stack/archive stack_data = { "metadata": {"name": "test_stack", "content_type": "stack/archive"}, - "launch": {"properties": {"launch_file": "test.launch.py"}} + "launch": {"properties": {"launch_file": "test.launch.py"}}, } request.input.current.stack = json.dumps(stack_data) @@ -500,7 +477,7 @@ def test_handle_apply_archive_stack(self, mock_launch_plugin): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_no_stack_data(self, mock_launch_plugin): """Test handle_apply with no valid stack data.""" request = MagicMock() @@ -512,7 +489,6 @@ def test_handle_apply_no_stack_data(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Provide minimal valid stack but mock payload detection to return None request.input.current.stack = json.dumps({"metadata": {"name": "test"}}) @@ -523,7 +499,7 @@ def test_handle_apply_no_stack_data(self, mock_launch_plugin): self.assertFalse(response.success) self.assertEqual(response.err_msg, "No current stack available or start flag not set.") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_no_current_stack(self, mock_launch_plugin): """Test handle_apply with no current stack.""" request = MagicMock() @@ -532,7 +508,6 @@ def test_handle_apply_no_current_stack(self, mock_launch_plugin): response.success = False response.err_msg = "" - request.input.current.stack = "" # Empty stack self.node.handle_apply(request, response) @@ -541,7 +516,7 @@ def test_handle_apply_no_current_stack(self, mock_launch_plugin): self.assertEqual(response.err_msg, "No current stack available or start flag not set.") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_json_payload(self, mock_launch_plugin, mock_source_workspace): """Test handle_start with stack/json payload.""" # Use real handlers for this test @@ -553,11 +528,10 @@ def test_handle_start_json_payload(self, mock_launch_plugin, mock_source_workspa request = MagicMock() request.start = True - # Use proper JSON structure for stack/json stack_data = { "metadata": {"name": "JSON Stack", "content_type": "stack/json"}, - "launch": {"node": [{"name": "test_node"}]} + "launch": {"node": [{"name": "test_node"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -569,7 +543,7 @@ def test_handle_start_json_payload(self, mock_launch_plugin, mock_source_workspa self.assertEqual(response.err_msg, "") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_raw_payload(self, mock_launch_plugin, mock_source_workspace): """Test handle_start with raw stack payload.""" # Use real handlers for this test @@ -581,11 +555,10 @@ def test_handle_start_raw_payload(self, mock_launch_plugin, mock_source_workspac request = MagicMock() request.start = True - # Use proper JSON structure for raw stack stack_data = { "node": [{"name": "test_node", "pkg": "test_pkg"}], - "metadata": {"name": "Raw Stack"} + "metadata": {"name": "Raw Stack"}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -596,7 +569,7 @@ def test_handle_start_raw_payload(self, mock_launch_plugin, mock_source_workspac self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_raw_payload(self, mock_launch_plugin): """Test handle_kill with raw stack payload.""" # Use real handlers for this test @@ -608,12 +581,8 @@ def test_handle_kill_raw_payload(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper JSON structure for raw stack - stack_data = { - "node": [{"name": "test_node"}], - "metadata": {"name": "test_stack"} - } + stack_data = {"node": [{"name": "test_node"}], "metadata": {"name": "test_stack"}} request.input.current.stack = json.dumps(stack_data) # Mock payload type detection for raw stack @@ -646,32 +615,25 @@ def test_get_stack_name_none_input(self): result = self.node._get_stack_name(None) self.assertEqual(result, "default") - @patch.object(MutoDefaultLaunchPlugin, "run_script") @patch.object(MutoDefaultLaunchPlugin, "find_file") - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_kill_not_script( - self, mock_launch_plugin, mock_find_file, mock_run_script - ): + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_kill_not_script(self, mock_launch_plugin, mock_find_file, mock_run_script): # Use real handlers for this test pass # Registry already initialized in node - + self.node.set_stack_cli = MagicMock() request = MagicMock() request.start = True - stack_data = { - "name": "test_stack", - "on_start": "start_script.sh", - "on_kill": True - } + stack_data = {"name": "test_stack", "on_start": "start_script.sh", "on_kill": True} request.input.current.stack = json.dumps(stack_data) response = MagicMock() response.success = False response.err_msg = "" mock_find_file.return_value = None - + # Mock the stack parser to return the stack data self.node.stack_parser.parse_payload.return_value = stack_data @@ -682,21 +644,16 @@ def test_handle_kill_not_script( self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.CoreTwin") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.CoreTwin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_no_current_stack(self, mock_launch_plugin, mock_core_twin): request = MagicMock() request.start = True # Set up stack data with unknown type so no handler found - stack_data = { - "metadata": { - "name": "test_stack", - "content_type": "unknown/type" - } - } + stack_data = {"metadata": {"name": "test_stack", "content_type": "unknown/type"}} request.input.current.stack = json.dumps(stack_data) - + # Mock: No handler found for this stack type self.node.stack_registry.get_handler = MagicMock(return_value=None) response = MagicMock() @@ -709,9 +666,9 @@ def test_handle_kill_no_current_stack(self, mock_launch_plugin, mock_core_twin): self.assertEqual(response.err_msg, "No current stack available or start flag not set.") mock_core_twin.assert_not_called() - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): # Use real handlers for this test pass # Registry already initialized in node @@ -720,20 +677,20 @@ def test_handle_apply(self, mock_launch_plugin, mock_stack_json, mock_stack_ditt response = MagicMock() response.success = False response.err_msg = "" - + # Use proper JSON structure for a raw stack (has no content_type, just nodes) stack_data = { "metadata": { "name": "mock_stack_name", - "description": "A mock stack for testing apply" + "description": "A mock stack for testing apply", }, - "node": [{"name": "test_node", "pkg": "test_pkg"}] + "node": [{"name": "test_node", "pkg": "test_pkg"}], } request.input.current.stack = json.dumps(stack_data) # Mock the stack parser to return the parsed payload self.node.stack_parser.parse_payload.return_value = stack_data - + # Mock Stack instances are already set up in setUp() # mock_stack_instance is available via self.mock_stack_instance mock_stack_instance = self.mock_stack_instance @@ -746,9 +703,9 @@ def test_handle_apply(self, mock_launch_plugin, mock_stack_json, mock_stack_ditt self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_apply_exception(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): request = MagicMock() response = MagicMock() @@ -770,7 +727,7 @@ def test_handle_apply_exception(self, mock_launch_plugin, mock_stack_json, mock_ mock_stack_json.assert_not_called() @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_stack_json_content_type(self, mock_launch_plugin, mock_source_workspace): """Test handle_start with stack/json content_type.""" # Use real handlers for this test @@ -782,17 +739,14 @@ def test_handle_start_stack_json_content_type(self, mock_launch_plugin, mock_sou response.success = False response.err_msg = "" - # Use proper JSON stack structure stack_data = { "metadata": { "name": "JSON Stack", "description": "A JSON content type stack", - "content_type": "stack/json" + "content_type": "stack/json", }, - "launch": { - "node": [{"name": "test_node"}] - } + "launch": {"node": [{"name": "test_node"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -804,8 +758,10 @@ def test_handle_start_stack_json_content_type(self, mock_launch_plugin, mock_sou self.assertEqual(response.err_msg, "") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_start_stack_archive_content_type(self, mock_launch_plugin, mock_source_workspace): + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_start_stack_archive_content_type( + self, mock_launch_plugin, mock_source_workspace + ): """Test handle_start with stack/archive content_type.""" request = MagicMock() request.start = True @@ -813,13 +769,12 @@ def test_handle_start_stack_archive_content_type(self, mock_launch_plugin, mock_ response.success = False response.err_msg = "" - # Use proper archive structure matching talker-listener-xarchive.json stack_data = { "metadata": { "name": "Muto Simple Talker-Listener Stack", "description": "A simple talker-listener stack example", - "content_type": "stack/archive" + "content_type": "stack/archive", }, "launch": { "data": "H4sIAAAAAAAAA+1de...", # truncated for test @@ -828,9 +783,9 @@ def test_handle_start_stack_archive_content_type(self, mock_launch_plugin, mock_ "checksum": "553fd2dc7d0eb41e7d65c467d358e7962d3efbb0e2f2e4f8158e926a081f96d0", "launch_file": "launch/talker_listener.launch.py", "command": "launch", - "flatten": True - } - } + "flatten": True, + }, + }, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -842,7 +797,7 @@ def test_handle_start_stack_archive_content_type(self, mock_launch_plugin, mock_ self.assertEqual(response.err_msg, "") @patch.object(MutoDefaultLaunchPlugin, "source_workspaces") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_start_raw_payload_type(self, mock_launch_plugin, mock_source_workspace): """Test handle_start with raw payload (node/composable).""" # Use real handlers for this test @@ -854,16 +809,10 @@ def test_handle_start_raw_payload_type(self, mock_launch_plugin, mock_source_wor response.success = False response.err_msg = "" - # Use proper structure for raw payload (legacy format without content_type) stack_data = { - "metadata": { - "name": "Raw Stack", - "description": "A raw payload stack example" - }, - "launch": { - "node": [{"name": "test_node", "pkg": "test_pkg", "exec": "test_exec"}] - } + "metadata": {"name": "Raw Stack", "description": "A raw payload stack example"}, + "launch": {"node": [{"name": "test_node", "pkg": "test_pkg", "exec": "test_exec"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -874,7 +823,7 @@ def test_handle_start_raw_payload_type(self, mock_launch_plugin, mock_source_wor self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_stack_json_content_type(self, mock_launch_plugin): """Test handle_kill with stack/json content_type.""" # Use real handlers for this test @@ -886,17 +835,14 @@ def test_handle_kill_stack_json_content_type(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper stack/json structure stack_data = { "metadata": { "name": "JSON Stack", "description": "A JSON content type stack", - "content_type": "stack/json" + "content_type": "stack/json", }, - "launch": { - "node": [{"name": "test_node"}] - } + "launch": {"node": [{"name": "test_node"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -908,7 +854,7 @@ def test_handle_kill_stack_json_content_type(self, mock_launch_plugin): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_stack_archive_content_type(self, mock_launch_plugin): """Test handle_kill with stack/archive content_type.""" # Use real handlers for this test @@ -920,13 +866,12 @@ def test_handle_kill_stack_archive_content_type(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper archive structure stack_data = { "metadata": { "name": "Archive Stack", "description": "An archive content type stack", - "content_type": "stack/archive" + "content_type": "stack/archive", }, "launch": { "data": "H4sIAAAAAAAAA+1de...", # truncated for test @@ -935,9 +880,9 @@ def test_handle_kill_stack_archive_content_type(self, mock_launch_plugin): "checksum": "553fd2dc7d0eb41e7d65c467d358e7962d3efbb0e2f2e4f8158e926a081f96d0", "launch_file": "launch/test.launch.py", "command": "launch", - "flatten": True - } - } + "flatten": True, + }, + }, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -949,7 +894,7 @@ def test_handle_kill_stack_archive_content_type(self, mock_launch_plugin): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch("composer.plugins.launch_plugin.LaunchPlugin") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") def test_handle_kill_raw_payload_type(self, mock_launch_plugin): """Test handle_kill with raw payload (node/composable).""" # Use real handlers for this test @@ -961,16 +906,13 @@ def test_handle_kill_raw_payload_type(self, mock_launch_plugin): response.success = False response.err_msg = "" - # Use proper raw payload structure (legacy format without content_type) stack_data = { "metadata": { "name": "Raw Kill Stack", - "description": "A raw payload stack for kill test" + "description": "A raw payload stack for kill test", }, - "launch": { - "composable": [{"name": "test_composable"}] - } + "launch": {"composable": [{"name": "test_composable"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -982,10 +924,12 @@ def test_handle_kill_raw_payload_type(self, mock_launch_plugin): self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_apply_stack_json_content_type(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_apply_stack_json_content_type( + self, mock_launch_plugin, mock_stack_json, mock_stack_ditto + ): """Test handle_apply with stack/json content_type.""" # Use real handlers for this test pass # Registry already initialized in node @@ -995,17 +939,14 @@ def test_handle_apply_stack_json_content_type(self, mock_launch_plugin, mock_sta response.success = False response.err_msg = "" - # Use proper JSON stack structure stack_data = { "metadata": { "name": "JSON Apply Stack", "description": "A JSON content type stack for apply test", - "content_type": "stack/json" + "content_type": "stack/json", }, - "launch": { - "node": [{"name": "test_node"}] - } + "launch": {"node": [{"name": "test_node"}]}, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -1018,10 +959,12 @@ def test_handle_apply_stack_json_content_type(self, mock_launch_plugin, mock_sta self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_apply_stack_archive_content_type(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_apply_stack_archive_content_type( + self, mock_launch_plugin, mock_stack_json, mock_stack_ditto + ): """Test handle_apply with stack/archive content_type.""" # Use real handlers for this test pass # Registry already initialized in node @@ -1031,13 +974,12 @@ def test_handle_apply_stack_archive_content_type(self, mock_launch_plugin, mock_ response.success = False response.err_msg = "" - # Use proper archive structure stack_data = { "metadata": { "name": "Archive Apply Stack", "description": "An archive content type stack for apply test", - "content_type": "stack/archive" + "content_type": "stack/archive", }, "launch": { "data": "H4sIAAAAAAAAA+1de...", # truncated for test @@ -1046,9 +988,9 @@ def test_handle_apply_stack_archive_content_type(self, mock_launch_plugin, mock_ "checksum": "553fd2dc7d0eb41e7d65c467d358e7962d3efbb0e2f2e4f8158e926a081f96d0", "launch_file": "launch/test.launch.py", "command": "launch", - "flatten": True - } - } + "flatten": True, + }, + }, } request.input.current.stack = json.dumps(stack_data) request.input.current.source = json.dumps({}) @@ -1057,14 +999,16 @@ def test_handle_apply_stack_archive_content_type(self, mock_launch_plugin, mock_ # Mock the methods that would be called for archive handling self.node.handle_apply(request, response) - + self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_apply_raw_payload_type(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_apply_raw_payload_type( + self, mock_launch_plugin, mock_stack_json, mock_stack_ditto + ): """Test handle_apply with raw payload (node/composable).""" # Use real handlers for this test pass # Registry already initialized in node @@ -1074,14 +1018,13 @@ def test_handle_apply_raw_payload_type(self, mock_launch_plugin, mock_stack_json response.success = False response.err_msg = "" - # Raw payload with node - use proper request structure raw_payload = { "metadata": { "name": "Raw Apply Stack", - "description": "A raw payload stack for apply test" + "description": "A raw payload stack for apply test", }, - "node": [{"name": "test_node"}] + "node": [{"name": "test_node"}], } request.input.current.stack = json.dumps(raw_payload) request.input.current.source = json.dumps({}) @@ -1094,10 +1037,12 @@ def test_handle_apply_raw_payload_type(self, mock_launch_plugin, mock_stack_json self.assertTrue(response.success) self.assertEqual(response.err_msg, "") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - @patch("composer.plugins.launch_plugin.LaunchPlugin") - def test_handle_apply_unknown_content_type(self, mock_launch_plugin, mock_stack_json, mock_stack_ditto): + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + @patch("muto_composer.plugins.launch_plugin.LaunchPlugin") + def test_handle_apply_unknown_content_type( + self, mock_launch_plugin, mock_stack_json, mock_stack_ditto + ): """Test handle_apply with unknown content_type uses full payload.""" request = MagicMock() response = MagicMock() @@ -1109,13 +1054,8 @@ def test_handle_apply_unknown_content_type(self, mock_launch_plugin, mock_stack_ # Payload with unknown content_type unknown_payload = { - "metadata": { - "name": "test-unknown-stack", - "content_type": "unknown/type" - }, - "custom": { - "data": "some_data" - } + "metadata": {"name": "test-unknown-stack", "content_type": "unknown/type"}, + "custom": {"data": "some_data"}, } request.input.current.stack = json.dumps(unknown_payload) request.input.current.source = json.dumps({}) @@ -1125,14 +1065,16 @@ def test_handle_apply_unknown_content_type(self, mock_launch_plugin, mock_stack_ # For unknown content type, get_handler returns None, so Stack should not be called mock_stack_json.assert_not_called() mock_stack_ditto.assert_not_called() - + # Should fail with appropriate error message self.assertFalse(response.success) self.assertEqual(response.err_msg, "No current stack available or start flag not set.") - @patch('composer.stack_handlers.ditto_handler.Stack') - @patch('composer.stack_handlers.json_handler.Stack') - def test_handle_start_stack_json_missing_launch_section(self, mock_stack_json, mock_stack_ditto): + @patch("muto_composer.stack_handlers.ditto_handler.Stack") + @patch("muto_composer.stack_handlers.json_handler.Stack") + def test_handle_start_stack_json_missing_launch_section( + self, mock_stack_json, mock_stack_ditto + ): """ Test that stack/json without launch section fails gracefully. """ @@ -1150,10 +1092,7 @@ def test_handle_start_stack_json_missing_launch_section(self, mock_stack_json, m # Stack data without launch section stack_data = { - "metadata": { - "name": "Invalid Stack", - "content_type": "stack/json" - } + "metadata": {"name": "Invalid Stack", "content_type": "stack/json"} # Missing launch section } request.input.current.stack = json.dumps(stack_data) @@ -1162,12 +1101,13 @@ def test_handle_start_stack_json_missing_launch_section(self, mock_stack_json, m # Mock the stack parser to return the stack data self.node.stack_parser.parse_payload.return_value = stack_data - with patch.object(self.node, 'source_workspaces'): + with patch.object(self.node, "source_workspaces"): self.node.handle_start(request, response) # Validation now catches missing launch section at find_stack_handler level # Response should fail because stack validation rejects malformed stacks early self.assertFalse(response.success) + if __name__ == "__main__": unittest.main() diff --git a/test/test_launcher.py b/test/test_launcher.py index 7e52964..9448024 100644 --- a/test/test_launcher.py +++ b/test/test_launcher.py @@ -14,15 +14,14 @@ import signal import unittest from unittest.mock import MagicMock, call, patch -from launch import LaunchDescription import rclpy +from launch import LaunchDescription -from composer.workflow.launcher import Ros2LaunchParent +from muto_composer.workflow.launcher import Ros2LaunchParent class TestLauncher(unittest.TestCase): - def setUp(self): self.manager = MagicMock() self._active_nodes = self.manager.list() @@ -52,9 +51,7 @@ def test_del_(self): def test_parse_launch_arguments(self): launch_list = ["launch_css:=false", "launch_sensor:=false"] - returned_value = Ros2LaunchParent.parse_launch_arguments( - self, launch_arguments=launch_list - ) + returned_value = Ros2LaunchParent.parse_launch_arguments(self, launch_arguments=launch_list) self.assertIn(("launch_css", "false"), returned_value) self.assertIn(("launch_sensor", "false"), returned_value) @@ -69,9 +66,7 @@ def test_multi_parse_launch_argument(self): "launch_sensor:=false", "launch_sensor:=true", ] - returned_value = Ros2LaunchParent.parse_launch_arguments( - self, launch_arguments=launch_list - ) + returned_value = Ros2LaunchParent.parse_launch_arguments(self, launch_arguments=launch_list) self.assertIn(("launch_css", "false"), returned_value) self.assertIn(("launch_sensor", "true"), returned_value) self.assertNotIn(("la0unch_sensor", "false"), returned_value) @@ -120,9 +115,7 @@ def test_kill_no_active_nodes(self, mock_kill, mock_logger): ros2launch_parent.kill() ros2launch_parent._lock.__enter__.assert_called_once() mock_kill.assert_not_called() - mock_logger.get_logger().info.assert_called_once_with( - "No active nodes to kill." - ) + mock_logger.get_logger().info.assert_called_once_with("No active nodes to kill.") @patch("rclpy.logging") @patch("os.kill") @@ -139,12 +132,8 @@ def test_kill_active_nodes(self, mock_kill, mock_logger): ) self.assertEqual(ros2launch_parent._active_nodes, []) # Check that both nodes were logged (order may vary due to parallel execution) - mock_logger.get_logger().info.assert_any_call( - "Sent SIGKILL to process node1 (PID 1234)" - ) - mock_logger.get_logger().info.assert_any_call( - "Sent SIGKILL to process node2 (PID 5678)" - ) + mock_logger.get_logger().info.assert_any_call("Sent SIGKILL to process node1 (PID 1234)") + mock_logger.get_logger().info.assert_any_call("Sent SIGKILL to process node2 (PID 5678)") @patch("rclpy.logging") @patch("os.kill", side_effect=ProcessLookupError) @@ -193,9 +182,7 @@ def test_kill_process(self, mock_kill, mock_logger): mock_process.join.assert_called_once_with(timeout=10.0) ros2launch_parent._stop_event.set.assert_called_once() self.assertEqual(ros2launch_parent._active_nodes, []) - mock_logger.get_logger().info.assert_called_with( - "Shutting down the launch service" - ) + mock_logger.get_logger().info.assert_called_with("Shutting down the launch service") mock_kill.assert_called_once() @patch("rclpy.logging") @@ -214,9 +201,7 @@ def test_kill_alive_process(self, mock_kill, mock_logger): mock_process.terminate.assert_called_once() mock_process.join.assert_called_once_with(timeout=10.0) self.assertEqual(ros2launch_parent._active_nodes, []) - mock_logger.get_logger().info.assert_called_with( - "Shutting down the launch service" - ) + mock_logger.get_logger().info.assert_called_with("Shutting down the launch service") mock_kill.assert_called_once() @patch("rclpy.logging") @@ -252,11 +237,9 @@ def test_run_process(self, mock_set, mock_new): mock_new.assert_called_once() mock_set.assert_called_once() - @patch("composer.workflow.launcher.Node") + @patch("muto_composer.workflow.launcher.Node") @patch("launch.LaunchDescription.add_action") - def test_create_launch_description_for_added_nodes( - self, mock_add_action, mock_node - ): + def test_create_launch_description_for_added_nodes(self, mock_add_action, mock_node): added_nodes = { "node1": { "name": "test_node", @@ -293,9 +276,7 @@ def test_kill_single_node(self, mock_kill): self.assertEqual(len(self.launch_parent._active_nodes), 1) self.assertEqual(self.launch_parent._active_nodes[0], {"node2": 5678}) - self.logger_mock.info.assert_any_call( - "Sent SIGKILL to process node1 (PID 1234)" - ) + self.logger_mock.info.assert_any_call("Sent SIGKILL to process node1 (PID 1234)") @patch("os.kill") def test_kill_multiple_nodes(self, mock_kill): @@ -326,9 +307,7 @@ def test_kill_already_terminated_node(self, mock_kill): with self.launch_parent._lock: self.assertEqual(len(self.launch_parent._active_nodes), 0) - self.logger_mock.info.assert_any_call( - "Process node1 (PID 1234) already terminated." - ) + self.logger_mock.info.assert_any_call("Process node1 (PID 1234) already terminated.") @patch("os.kill", side_effect=PermissionError("No permission")) def test_kill_node_permission_error(self, mock_kill): @@ -359,5 +338,4 @@ def test_kill_nodes_with_no_active_nodes(self, mock_kill): if __name__ == "__main__": - unittest.main() diff --git a/test/test_launcher_async.py b/test/test_launcher_async.py index 886a775..541597e 100644 --- a/test/test_launcher_async.py +++ b/test/test_launcher_async.py @@ -12,9 +12,11 @@ # import unittest -from unittest.mock import MagicMock, patch, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch + import rclpy -from composer.workflow.launcher import Ros2LaunchParent + +from muto_composer.workflow.launcher import Ros2LaunchParent class TestLauncherAsync(unittest.IsolatedAsyncioTestCase): @@ -30,10 +32,10 @@ def tearDownClass(cls) -> None: rclpy.shutdown() @patch("rclpy.logging") - @patch("composer.workflow.launcher.OnProcessExit") - @patch("composer.workflow.launcher.RegisterEventHandler") + @patch("muto_composer.workflow.launcher.OnProcessExit") + @patch("muto_composer.workflow.launcher.RegisterEventHandler") @patch.object(Ros2LaunchParent, "parse_launch_arguments") - @patch("composer.workflow.launcher.launch") + @patch("muto_composer.workflow.launcher.launch") async def test_launch_a_launch_file_test( self, mock_launch, @@ -63,9 +65,7 @@ async def test_launch_a_launch_file_test( mock_launch.LaunchDescription.assert_called_once_with( [mock_launch.actions.IncludeLaunchDescription()] ) - mock_launch.LaunchDescription().add_action.assert_called_with( - mock_register_event_handler() - ) + mock_launch.LaunchDescription().add_action.assert_called_with(mock_register_event_handler()) self.assertEqual(mock_launch.LaunchDescription().add_action.call_count, 2) mock_logger.get_logger().info.assert_called_once() mock_on_process_exit.assert_called_once() diff --git a/test/test_message_handler.py b/test/test_message_handler.py index 5464479..fd8a6e5 100644 --- a/test/test_message_handler.py +++ b/test/test_message_handler.py @@ -11,190 +11,189 @@ # Composiv.ai - initial API and implementation # +import contextlib import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock + import rclpy from muto_msgs.msg import MutoAction -from composer.events import EventBus, EventType, StackRequestEvent -from composer.subsystems.message_handler import MessageHandler, MessageRouter -from composer.subsystems.stack_manager import StackType + +from muto_composer.events import EventBus, EventType +from muto_composer.subsystems.message_handler import MessageHandler, MessageRouter +from muto_composer.subsystems.stack_manager import StackType class TestMessageRouter(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() self.router = MessageRouter(self.event_bus, self.logger) - + def test_route_muto_action_with_value_key(self): """Test routing MutoAction with value key.""" # Setup event capture routed_events = [] - + def capture_event(event): routed_events.append(event) - + self.event_bus.subscribe(EventType.STACK_REQUEST, capture_event) - + # Create MutoAction message muto_action = MutoAction() muto_action.method = "start" muto_action.payload = '{"value": {"stackId": "test_stack"}}' - + # Route the message self.router.route_muto_action(muto_action) - + # Verify event was published self.assertEqual(len(routed_events), 1) event = routed_events[0] self.assertEqual(event.action, "start") self.assertEqual(event.stack_name, "test_stack") - + def test_route_muto_action_direct_payload(self): """Test routing MutoAction with direct payload.""" # Setup event capture routed_events = [] - + def capture_event(event): routed_events.append(event) - + self.event_bus.subscribe(EventType.STACK_REQUEST, capture_event) - + # Create MutoAction message with direct payload muto_action = MutoAction() muto_action.method = "apply" muto_action.payload = '{"node": ["test_node"], "metadata": {"name": "direct_stack"}}' - + # Route the message self.router.route_muto_action(muto_action) - + # Verify event was published self.assertEqual(len(routed_events), 1) event = routed_events[0] self.assertEqual(event.action, "apply") self.assertEqual(event.stack_name, "direct_stack") self.assertIn("node", event.stack_payload) - + def test_route_muto_action_invalid_json(self): """Test routing MutoAction with invalid JSON.""" muto_action = MutoAction() muto_action.method = "start" - muto_action.payload = 'invalid json' - + muto_action.payload = "invalid json" + # Should not raise exception, should log error self.router.route_muto_action(muto_action) - + # Verify error was logged self.logger.error.assert_called() - + def test_extract_stack_name_from_value_key(self): """Test stack name extraction from value key.""" payload = {"value": {"stackId": "test_stack_123"}} - + stack_name = self.router._extract_stack_name(payload, "test_namespace:test_device") - + self.assertEqual(stack_name, "test_stack_123") - + def test_extract_stack_name_from_metadata(self): """Test stack name extraction from metadata.""" payload = {"metadata": {"name": "metadata_stack"}} - + stack_name = self.router._extract_stack_name(payload, "test_namespace:test_device") - + self.assertEqual(stack_name, "metadata_stack") - + def test_extract_stack_name_fallback(self): """Test stack name extraction fallback to default.""" payload = {"some": "data"} default_name = "test_namespace:test_device" - + stack_name = self.router._extract_stack_name(payload, default_name) - + self.assertEqual(stack_name, default_name) class TestMessageHandler(unittest.TestCase): - def setUp(self): # Initialize ROS if not already done - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass - + # Create a mock node self.mock_node = MagicMock() self.mock_node.get_logger.return_value = MagicMock() - self.mock_node.get_parameter.return_value.get_parameter_value.return_value.string_value = "test_value" - + self.mock_node.get_parameter.return_value.get_parameter_value.return_value.string_value = ( + "test_value" + ) + self.event_bus = EventBus() self.logger = MagicMock() - + self.message_handler = MessageHandler(self.mock_node, self.event_bus, self.logger) - + def tearDown(self): - try: + with contextlib.suppress(BaseException): rclpy.shutdown() - except: - pass - + def test_initialization(self): """Test MessageHandler initialization.""" self.assertIsNotNone(self.message_handler.router) self.assertIsNotNone(self.message_handler.publisher_manager) self.assertIsNotNone(self.message_handler.service_client_manager) - + def test_handle_muto_action(self): """Test handling MutoAction message.""" # Setup event capture handled_events = [] - + def capture_event(event): handled_events.append(event) - + self.event_bus.subscribe(EventType.STACK_REQUEST, capture_event) - + # Create MutoAction muto_action = MutoAction() muto_action.method = "start" muto_action.payload = '{"value": {"stackId": "test_stack"}}' - + # Handle the message self.message_handler.handle_muto_action(muto_action) - + # Verify event was generated self.assertEqual(len(handled_events), 1) event = handled_events[0] self.assertEqual(event.action, "start") self.assertEqual(event.stack_name, "test_stack") - + def test_get_components(self): """Test getting individual components.""" router = self.message_handler.get_router() publisher_manager = self.message_handler.get_publisher_manager() service_client_manager = self.message_handler.get_service_client_manager() - + self.assertIsNotNone(router) self.assertIsNotNone(publisher_manager) self.assertIsNotNone(service_client_manager) - + def test_integration_with_composer_flow(self): """Test integration with overall composer flow.""" # This would test the complete flow from MutoAction to events # Setup multiple event captures to verify the full chain - + stack_requests = [] - + def capture_stack_request(event): stack_requests.append(event) - + self.event_bus.subscribe(EventType.STACK_REQUEST, capture_stack_request) - + # Simulate receiving a complex MutoAction muto_action = MutoAction() muto_action.method = "apply" - muto_action.payload = ''' + muto_action.payload = """ { "metadata": { "name": "integration_test_stack", @@ -205,15 +204,15 @@ def capture_stack_request(event): "test_param": "value" } } - ''' - + """ + # Handle the message self.message_handler.handle_muto_action(muto_action) - + # Verify the complete event was created correctly self.assertEqual(len(stack_requests), 1) request = stack_requests[0] - + self.assertEqual(request.action, "apply") self.assertEqual(request.stack_name, "integration_test_stack") self.assertIn("node", request.stack_payload) @@ -221,5 +220,5 @@ def capture_stack_request(event): self.assertEqual(request.stack_payload["metadata"]["content_type"], StackType.JSON.value) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/test/test_muto_composer.py b/test/test_muto_composer.py index 52b3d42..8082246 100644 --- a/test/test_muto_composer.py +++ b/test/test_muto_composer.py @@ -11,17 +11,23 @@ # Composiv.ai - initial API and implementation # +import contextlib +import json import unittest +from unittest.mock import patch + import rclpy -import json -import asyncio -from unittest.mock import MagicMock, patch, AsyncMock from muto_msgs.msg import MutoAction -from composer.muto_composer import MutoComposer -from composer.events import ( - EventBus, EventType, StackRequestEvent, StackAnalyzedEvent, - StackProcessedEvent, OrchestrationStartedEvent, PipelineEvents + +from muto_composer.events import ( + EventType, + OrchestrationStartedEvent, + PipelineEvents, + StackAnalyzedEvent, + StackProcessedEvent, + StackRequestEvent, ) +from muto_composer.muto_composer import MutoComposer class TestMutoComposerIntegration(unittest.TestCase): @@ -33,47 +39,41 @@ class TestMutoComposerIntegration(unittest.TestCase): def setUp(self) -> None: # Initialize ROS if not already done - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass - + # Mock node creation to avoid actual ROS initialization - with patch('composer.muto_composer.MutoComposer._initialize_subsystems'), \ - patch('composer.muto_composer.MutoComposer._setup_ros_interfaces'): + with ( + patch("muto_composer.muto_composer.MutoComposer._initialize_subsystems"), + patch("muto_composer.muto_composer.MutoComposer._setup_ros_interfaces"), + ): self.composer = MutoComposer() - + # Setup test components self.test_events = [] self.captured_events = {} - + # Setup event capture for all event types for event_type in EventType: self.captured_events[event_type] = [] self.composer.event_bus.subscribe( - event_type, - lambda event, et=event_type: self.captured_events[et].append(event) + event_type, lambda event, et=event_type: self.captured_events[et].append(event) ) def tearDown(self) -> None: - try: + with contextlib.suppress(BaseException): self.composer.destroy_node() - except: - pass @classmethod def setUpClass(cls) -> None: - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass @classmethod def tearDownClass(cls) -> None: - try: + with contextlib.suppress(BaseException): rclpy.shutdown() - except: - pass + # # Copyright (c) 2025 Composiv.ai @@ -89,16 +89,6 @@ def tearDownClass(cls) -> None: # import unittest -import rclpy -import json -import asyncio -from unittest.mock import MagicMock, patch, AsyncMock -from muto_msgs.msg import MutoAction -from composer.muto_composer import MutoComposer -from composer.events import ( - EventBus, EventType, StackRequestEvent, StackAnalyzedEvent, - StackProcessedEvent, OrchestrationStartedEvent, PipelineEvents -) class TestMutoComposerIntegration(unittest.TestCase): @@ -110,63 +100,52 @@ class TestMutoComposerIntegration(unittest.TestCase): def setUp(self) -> None: # Initialize ROS if not already done - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass - + # Mock node creation to avoid actual ROS initialization - with patch('composer.muto_composer.MutoComposer._initialize_subsystems'), \ - patch('composer.muto_composer.MutoComposer._setup_ros_interfaces'): + with ( + patch("muto_composer.muto_composer.MutoComposer._initialize_subsystems"), + patch("muto_composer.muto_composer.MutoComposer._setup_ros_interfaces"), + ): self.composer = MutoComposer() - + # Setup test components self.test_events = [] self.captured_events = {} - + # Setup event capture for all event types for event_type in EventType: self.captured_events[event_type] = [] self.composer.event_bus.subscribe( - event_type, - lambda event, et=event_type: self.captured_events[et].append(event) + event_type, lambda event, et=event_type: self.captured_events[et].append(event) ) def tearDown(self) -> None: - try: + with contextlib.suppress(BaseException): self.composer.destroy_node() - except: - pass @classmethod def setUpClass(cls) -> None: - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass @classmethod def tearDownClass(cls) -> None: - try: + with contextlib.suppress(BaseException): rclpy.shutdown() - except: - pass def test_muto_action_to_stack_request_flow(self): """Test the complete flow from MutoAction to StackRequest event.""" # Create a MutoAction message muto_action = MutoAction() muto_action.method = "start" - muto_action.payload = json.dumps({ - "value": { - "stackId": "test_stack_001" - } - }) - + muto_action.payload = json.dumps({"value": {"stackId": "test_stack_001"}}) + # Process the MutoAction through message handler - if hasattr(self.composer, 'message_handler'): + if hasattr(self.composer, "message_handler"): self.composer.message_handler.handle_muto_action(muto_action) - + # Verify StackRequest event was generated stack_requests = self.captured_events.get(EventType.STACK_REQUEST, []) if stack_requests: @@ -184,51 +163,42 @@ def test_stack_analysis_integration_flow(self): stack_name="integration_test_stack", stack_payload={ "metadata": {"name": "integration_test_stack"}, - "nodes": [{"name": "test_node", "pkg": "test_pkg"}] - } + "nodes": [{"name": "test_node", "pkg": "test_pkg"}], + }, ) - + # Publish the event self.composer.event_bus.publish_sync(stack_request) - + # In a real system, StackManager would process this and emit StackAnalyzed # For testing, we simulate the expected behavior - analyzed_events = self.captured_events.get(EventType.STACK_ANALYZED, []) + self.captured_events.get(EventType.STACK_ANALYZED, []) # Would verify that StackManager processed the request - + # Verify event was received (integration test for event flow) - self.assertTrue(hasattr(self.composer, 'event_bus')) + self.assertTrue(hasattr(self.composer, "event_bus")) def test_complete_stack_processing_pipeline(self): """Test the complete pipeline from stack request to pipeline execution.""" # Setup a complete stack payload stack_payload = { - "metadata": { - "name": "complete_test_stack", - "content_type": "stack/json" - }, + "metadata": {"name": "complete_test_stack", "content_type": "stack/json"}, "launch": { - "node": [ - { - "name": "test_node_1", - "pkg": "test_package", - "exec": "test_executable" - } - ] - } + "node": [{"name": "test_node_1", "pkg": "test_package", "exec": "test_executable"}] + }, } - + # Create and publish StackRequest request_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test_client", action="apply", stack_name="complete_test_stack", - stack_payload=stack_payload + stack_payload=stack_payload, ) - + self.composer.event_bus.publish_sync(request_event) - + # Simulate the processing chain # 1. StackAnalyzed event analyzed_event = StackAnalyzedEvent( @@ -237,37 +207,37 @@ def test_complete_stack_processing_pipeline(self): stack_name="complete_test_stack", action="apply", analysis_result={"stack_type": "stack/json"}, - processing_requirements={"runtime": "docker", "launch_required": True} + processing_requirements={"runtime": "docker", "launch_required": True}, ) - + self.composer.event_bus.publish_sync(analyzed_event) - - # 2. StackProcessed event + + # 2. StackProcessed event processed_event = StackProcessedEvent( stack_name="complete_test_stack", stack_payload=stack_payload, # Updated to use new standardized parameter name - execution_requirements={"runtime": "docker", "launch_required": True} + execution_requirements={"runtime": "docker", "launch_required": True}, ) - + self.composer.event_bus.publish_sync(processed_event) - + # 3. OrchestrationStarted event orchestration_event = OrchestrationStartedEvent( event_type=EventType.ORCHESTRATION_STARTED, source_component="test_orchestrator", action="apply", execution_plan={"steps": ["provision", "launch"]}, - orchestration_id="test_orchestration_001" + orchestration_id="test_orchestration_001", ) - + self.composer.event_bus.publish_sync(orchestration_event) - + # Verify events were captured in the integration flow requests = self.captured_events.get(EventType.STACK_REQUEST, []) analyzed = self.captured_events.get(EventType.STACK_ANALYZED, []) processed = self.captured_events.get(EventType.STACK_PROCESSED, []) orchestration = self.captured_events.get(EventType.ORCHESTRATION_STARTED, []) - + self.assertTrue(len(requests) > 0) self.assertTrue(len(analyzed) > 0) self.assertTrue(len(processed) > 0) @@ -277,24 +247,23 @@ def test_pipeline_execution_event_flow(self): """Test pipeline execution through event flows.""" # Create pipeline events pipeline_start = PipelineEvents.create_start_event( - pipeline_name="test_pipeline", - context={"stack_name": "test_stack", "action": "apply"} + pipeline_name="test_pipeline", context={"stack_name": "test_stack", "action": "apply"} ) - + pipeline_complete = PipelineEvents.create_completion_event( pipeline_name="test_pipeline", success=True, - result={"deployed": True, "nodes": ["node1"]} + result={"deployed": True, "nodes": ["node1"]}, ) - + # Publish pipeline events self.composer.event_bus.publish_sync(pipeline_start) self.composer.event_bus.publish_sync(pipeline_complete) - + # Verify pipeline events were captured start_events = self.captured_events.get(EventType.PIPELINE_START, []) complete_events = self.captured_events.get(EventType.PIPELINE_COMPLETE, []) - + self.assertTrue(len(start_events) > 0) self.assertTrue(len(complete_events) > 0) @@ -304,16 +273,16 @@ def test_error_handling_in_event_flows(self): error_event = PipelineEvents.create_error_event( pipeline_name="failing_pipeline", error="Test error condition", - context={"stack_name": "error_test_stack"} + context={"stack_name": "error_test_stack"}, ) - + # Publish error event self.composer.event_bus.publish_sync(error_event) - + # Verify error event was captured error_events = self.captured_events.get(EventType.PIPELINE_ERROR, []) self.assertTrue(len(error_events) > 0) - + if error_events: captured_error = error_events[0] self.assertEqual(captured_error.error_details["error"], "Test error condition") @@ -321,17 +290,10 @@ def test_error_handling_in_event_flows(self): def test_subsystem_isolation_through_events(self): """Test that subsystems communicate only through events.""" # Verify that the composer has the expected subsystems - self.assertTrue(hasattr(self.composer, 'event_bus')) - + self.assertTrue(hasattr(self.composer, "event_bus")) + # Verify subsystems exist (would be initialized in real system) - subsystem_attrs = [ - 'stack_manager', - 'orchestration_manager', - 'pipeline_engine', - 'message_handler', - 'digital_twin_integration' - ] - + # In the new architecture, these would be initialized # For now, verify the event bus is the communication mechanism self.assertIsNotNone(self.composer.event_bus) @@ -340,12 +302,12 @@ def test_backward_compatibility_methods(self): """Test that backward compatibility methods are available.""" # These methods should exist but be marked as deprecated deprecated_methods = [ - 'on_stack_callback', - 'resolve_expression', - 'determine_execution_path', - 'merge' + "on_stack_callback", + "resolve_expression", + "determine_execution_path", + "merge", ] - + for method_name in deprecated_methods: if hasattr(self.composer, method_name): method = getattr(self.composer, method_name) @@ -355,27 +317,27 @@ def test_event_bus_integration(self): """Test that event bus is properly integrated with composer.""" # Verify event bus exists self.assertIsNotNone(self.composer.event_bus) - + # Test event publishing and subscription test_events = [] - + def test_handler(event): test_events.append(event) - + # Subscribe to a test event self.composer.event_bus.subscribe(EventType.STACK_REQUEST, test_handler) - + # Publish a test event test_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test_client", action="test", stack_name="test_stack", - stack_payload={"test": "data"} + stack_payload={"test": "data"}, ) - + self.composer.event_bus.publish_sync(test_event) - + # Verify event was received self.assertEqual(len(test_events), 1) self.assertEqual(test_events[0].action, "test") @@ -386,22 +348,19 @@ def test_digital_twin_integration_flow(self): processed_event = StackProcessedEvent( stack_name="twin_test_stack", stack_payload={ # Updated to use new standardized parameter name - "metadata": { - "name": "twin_test_stack", - "twin_id": "test_twin_001" - }, - "nodes": [{"name": "twin_node"}] + "metadata": {"name": "twin_test_stack", "twin_id": "test_twin_001"}, + "nodes": [{"name": "twin_node"}], }, - execution_requirements={"runtime": "docker"} + execution_requirements={"runtime": "docker"}, ) - + # Publish the event self.composer.event_bus.publish_sync(processed_event) - + # Verify the event was captured processed_events = self.captured_events.get(EventType.STACK_PROCESSED, []) self.assertTrue(len(processed_events) > 0) - + if processed_events: event = processed_events[0] self.assertIn("twin_id", event.merged_stack["metadata"]) @@ -410,19 +369,19 @@ def test_message_routing_integration(self): """Test message routing integration with event system.""" # Test that MutoAction messages are properly routed to events # This tests the integration between MessageHandler and EventBus - + # Create different types of MutoAction messages test_actions = [ ("start", {"value": {"stackId": "start_test"}}), ("apply", {"metadata": {"name": "apply_test"}, "nodes": ["node1"]}), - ("stop", {"value": {"stackId": "stop_test"}}) + ("stop", {"value": {"stackId": "stop_test"}}), ] - + for method, payload in test_actions: muto_action = MutoAction() muto_action.method = method muto_action.payload = json.dumps(payload) - + # In real system, this would be handled by message_handler # For integration test, verify the structure is correct self.assertEqual(muto_action.method, method) @@ -436,20 +395,18 @@ class TestMutoComposerLegacyCompatibility(unittest.TestCase): """ def setUp(self) -> None: - try: + with contextlib.suppress(BaseException): rclpy.init() - except: - pass - - with patch('composer.muto_composer.MutoComposer._initialize_subsystems'), \ - patch('composer.muto_composer.MutoComposer._setup_ros_interfaces'): + + with ( + patch("muto_composer.muto_composer.MutoComposer._initialize_subsystems"), + patch("muto_composer.muto_composer.MutoComposer._setup_ros_interfaces"), + ): self.composer = MutoComposer() def tearDown(self) -> None: - try: + with contextlib.suppress(BaseException): self.composer.destroy_node() - except: - pass def test_legacy_stack_parser_compatibility(self): """Test backward compatibility with stack parser.""" @@ -457,34 +414,30 @@ def test_legacy_stack_parser_compatibility(self): test_payloads = [ {"value": {"stackId": "legacy_test"}}, {"metadata": {"name": "direct_test"}, "nodes": ["node1"]}, - {"unknown": "format"} + {"unknown": "format"}, ] - + for payload in test_payloads: - if hasattr(self.composer, 'stack_parser'): + if hasattr(self.composer, "stack_parser"): result = self.composer.stack_parser.parse_payload(payload) # Verify parsing doesn't crash self.assertTrue(result is not None or result is None) def test_legacy_merge_functionality(self): """Test backward compatibility for merge functionality.""" - if hasattr(self.composer, 'merge'): + if hasattr(self.composer, "merge"): stack1 = {"nodes": [{"name": "node1"}]} stack2 = {"nodes": [{"name": "node2"}]} - + merged = self.composer.merge(stack1, stack2) # Verify merge works without crashing self.assertIsNotNone(merged) def test_legacy_expression_resolution(self): """Test backward compatibility for expression resolution.""" - if hasattr(self.composer, 'resolve_expression'): - test_expressions = [ - "$(find test_pkg)", - "$(env TEST_VAR)", - "no expression here" - ] - + if hasattr(self.composer, "resolve_expression"): + test_expressions = ["$(find test_pkg)", "$(env TEST_VAR)", "no expression here"] + for expr in test_expressions: try: result = self.composer.resolve_expression(expr) diff --git a/test/test_param.py b/test/test_param.py index 00d3676..ed4ea12 100644 --- a/test/test_param.py +++ b/test/test_param.py @@ -12,13 +12,13 @@ # import unittest -import composer.model.param as param from unittest.mock import MagicMock, patch +import muto_composer.model.param as param -class TestParam(unittest.TestCase): - @patch("composer.model.param.Param._resolve_value") +class TestParam(unittest.TestCase): + @patch("muto_composer.model.param.Param._resolve_value") def setUp(self, mock_resolve_value): self.mock_stack = MagicMock() self.param = param.Param( diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 78fa5bf..f825553 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -12,11 +12,12 @@ # import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import rclpy from rclpy.node import Node -from composer.workflow.pipeline import Pipeline +from muto_composer.workflow.pipeline import Pipeline class TestPipeline(unittest.TestCase): @@ -179,9 +180,7 @@ def create_client_side_effect(plugin, service_name): elif service_name == "muto_kill_stack": future_mock.result.return_value = MagicMock(success=True, err_msg="") else: - future_mock.result.return_value = MagicMock( - success=True, err_msg="Unreached" - ) + future_mock.result.return_value = MagicMock(success=True, err_msg="Unreached") client_mock.call_async.return_value = future_mock return client_mock diff --git a/test/test_provision_plugin.py b/test/test_provision_plugin.py index e9c1fbf..af7c445 100644 --- a/test/test_provision_plugin.py +++ b/test/test_provision_plugin.py @@ -12,19 +12,15 @@ # import json -import os -import subprocess import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock import rclpy -from composer.plugins.provision_plugin import MutoProvisionPlugin -from composer.utils.paths import WORKSPACES_PATH +from muto_composer.plugins.provision_plugin import MutoProvisionPlugin class TestMutoProvisionPlugin(unittest.TestCase): - @classmethod def setUpClass(cls): rclpy.init() @@ -40,18 +36,9 @@ def setUp(self): def _create_stack_json(self, content_type="stack/json", name="Test Stack", **kwargs): """Helper to create proper stack JSON for tests.""" - stack = { - "metadata": { - "name": name, - "content_type": content_type - } - } + stack = {"metadata": {"name": name, "content_type": content_type}} stack.update(kwargs) return json.dumps(stack) - def tearDown(self): self.node.destroy_node() - - - diff --git a/test/test_router.py b/test/test_router.py index 77fd5a5..7e358f2 100644 --- a/test/test_router.py +++ b/test/test_router.py @@ -16,11 +16,10 @@ import rclpy -from composer.workflow.router import Router +from muto_composer.workflow.router import Router class TestRouter(unittest.TestCase): - def setUp(self): self.pipelines = { "start": MagicMock(), @@ -39,7 +38,7 @@ def tearDownClass(cls) -> None: rclpy.shutdown() @patch("rclpy.logging") - @patch("composer.workflow.router.Pipeline.execute_pipeline") + @patch("muto_composer.workflow.router.Pipeline.execute_pipeline") def test_route(self, mock_pipeline, mock_logging): main_route = Router(self.pipelines) main_route.route(self.payload.get("action", "")) @@ -53,9 +52,7 @@ def test_route_no_pipeline(self, mock_logging): main_route = Router(self.pipelines) main_route.route(self.payload.get("action", "")) self.pipeline.execute_pipeline.assert_not_called() - mock_logging.get_logger().warn.assert_called_once_with( - "No pipeline found for action: kill" - ) + mock_logging.get_logger().warn.assert_called_once_with("No pipeline found for action: kill") if __name__ == "__main__": diff --git a/test/test_safe_evaluator.py b/test/test_safe_evaluator.py index bd0c3c1..72b7119 100644 --- a/test/test_safe_evaluator.py +++ b/test/test_safe_evaluator.py @@ -12,7 +12,8 @@ # import unittest -from composer.workflow.safe_evaluator import SafeEvaluator + +from muto_composer.workflow.safe_evaluator import SafeEvaluator class TestSafeEvaluator(unittest.TestCase): diff --git a/test/test_stack.py b/test/test_stack.py index c670f1c..24f5242 100644 --- a/test/test_stack.py +++ b/test/test_stack.py @@ -12,13 +12,14 @@ # import unittest +from unittest.mock import MagicMock, patch + import rclpy -from unittest.mock import patch, MagicMock -from composer.model.stack import Stack +from muto_composer.model.stack import Stack -class TestStack(unittest.TestCase): +class TestStack(unittest.TestCase): def setUp(self): self.sample_manifest = { "name": "test_stack", @@ -194,9 +195,7 @@ def test_merge(self): def test_merge_attributes(self): stack1 = Stack(manifest=self.sample_manifest) - stack2 = Stack( - manifest={"name": "new_name", "context": "new_context", "stackId": "new_id"} - ) + stack2 = Stack(manifest={"name": "new_name", "context": "new_context", "stackId": "new_id"}) merged = Stack(manifest={}) stack1._merge_attributes(merged, stack2) @@ -261,7 +260,7 @@ def test_merge_params(self): self.assertIn("param1", param_names) self.assertIn("new_param", param_names) - @patch("composer.model.stack.rclpy") + @patch("muto_composer.model.stack.rclpy") def test_get_active_nodes(self, mock_rclpy): mock_node = MagicMock() mock_node.get_node_names_and_namespaces.return_value = [ @@ -276,12 +275,10 @@ def test_get_active_nodes(self, mock_rclpy): self.assertEqual(len(active_nodes), 2) self.assertIn(("/ns", "node1"), active_nodes) self.assertIn(("/", "node2"), active_nodes) - mock_rclpy.create_node.assert_called_once_with( - "get_active_nodes", enable_rosout=False - ) + mock_rclpy.create_node.assert_called_once_with("get_active_nodes", enable_rosout=False) mock_node.destroy_node.assert_called_once() - @patch("composer.model.stack.Introspector") + @patch("muto_composer.model.stack.Introspector") def test_kill_all(self, mock_introspector): mock_launcher = MagicMock() mock_launcher._active_nodes = [{"node1": 1234}, {"node2": 5678}] @@ -292,7 +289,7 @@ def test_kill_all(self, mock_introspector): mock_introspector.return_value.kill.assert_any_call("node1", 1234) mock_introspector.return_value.kill.assert_any_call("node2", 5678) - @patch("composer.model.stack.Introspector") + @patch("muto_composer.model.stack.Introspector") def test_kill_diff(self, mock_introspector): mock_launcher = MagicMock() mock_launcher._active_nodes = [{"test_exec": 1234}, {"other_exec": 5678}] @@ -305,20 +302,16 @@ def test_kill_diff(self, mock_introspector): mock_introspector.return_value.kill.assert_called_once_with("test_exec", 1234) - @patch("composer.model.stack.subprocess.run") + @patch("muto_composer.model.stack.subprocess.run") def test_change_params_at_runtime(self, mock_run): param_differences = { - ("node1", "node2"): [ - {"key": "param1", "in_node1": "value1", "in_node2": "value2"} - ] + ("node1", "node2"): [{"key": "param1", "in_node1": "value1", "in_node2": "value2"}] } stack = Stack(manifest=self.sample_manifest) stack.change_params_at_runtime(param_differences) - mock_run.assert_called_once_with( - ["ros2", "param", "set", "node1", "param1", "value1"] - ) + mock_run.assert_called_once_with(["ros2", "param", "set", "node1", "param1", "value1"]) def test_toShallowManifest(self): stack = Stack(manifest=self.sample_manifest) @@ -360,28 +353,31 @@ def test_process_remaps(self): self.assertEqual(len(processed_remaps), 1) self.assertEqual(processed_remaps[0], ("from_topic", "to_topic")) - @patch("composer.model.stack.Introspector") - @patch("composer.model.stack.LaunchDescription") + @patch("muto_composer.model.stack.Introspector") + @patch("muto_composer.model.stack.LaunchDescription") def test_launch(self, mock_launch_desc, mock_introspector): stack = Stack(manifest=self.sample_manifest) mock_launcher = MagicMock() - with patch("composer.model.stack.ComposableNodeContainer"), patch( - "composer.model.stack.ComposableNode" - ), patch("composer.model.stack.Node"): + with ( + patch("muto_composer.model.stack.ComposableNodeContainer"), + patch("muto_composer.model.stack.ComposableNode"), + patch("muto_composer.model.stack.Node"), + ): stack.launch(mock_launcher) mock_launcher.start.assert_called_once() - @patch("composer.model.stack.Introspector") - @patch("composer.model.stack.LaunchDescription") + @patch("muto_composer.model.stack.Introspector") + @patch("muto_composer.model.stack.LaunchDescription") def test_apply(self, mock_launch_desc, mock_introspector): stack = Stack(manifest=self.sample_manifest) mock_launcher = MagicMock() - with patch.object(Stack, "kill_diff") as mock_kill_diff, patch.object( - Stack, "launch" - ) as mock_launch: + with ( + patch.object(Stack, "kill_diff") as mock_kill_diff, + patch.object(Stack, "launch") as mock_launch, + ): stack.apply(mock_launcher) mock_kill_diff.assert_called_once_with(mock_launcher, stack) @@ -391,7 +387,7 @@ def test_resolve_expression(self): stack = Stack(manifest=self.sample_manifest) with patch( - "composer.model.stack.get_package_share_directory", + "muto_composer.model.stack.get_package_share_directory", return_value="/fake/path", ): result = stack.resolve_expression("$(find test_pkg)") diff --git a/test/test_stack_analyzed_processing.py b/test/test_stack_analyzed_processing.py index eb08bc8..4556e3a 100644 --- a/test/test_stack_analyzed_processing.py +++ b/test/test_stack_analyzed_processing.py @@ -7,24 +7,24 @@ import unittest from unittest.mock import MagicMock, patch -from composer.events import EventBus, EventType, StackAnalyzedEvent, StackProcessedEvent -from composer.subsystems.stack_manager import StackProcessor + +from muto_composer.events import EventType, StackAnalyzedEvent, StackProcessedEvent +from muto_composer.subsystems.stack_manager import StackProcessor class TestStackAnalyzedProcessing(unittest.TestCase): - def setUp(self): self.event_bus = MagicMock() self.logger = MagicMock() - + # Mock the stack parser - with patch('composer.subsystems.stack_manager.create_stack_parser') as mock_parser: + with patch("muto_composer.subsystems.stack_manager.create_stack_parser") as mock_parser: mock_parser.return_value = MagicMock() self.processor = StackProcessor(self.event_bus, self.logger) - + def test_handle_stack_analyzed_with_merge_processing(self): """Test that merge processing is applied and results flow through.""" - + # Create test event with merge processing requirement test_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -33,32 +33,32 @@ def test_handle_stack_analyzed_with_merge_processing(self): stack_name="test_stack", action="start", stack_payload={"test": "original"}, - processing_requirements={"merge_manifests": True} + processing_requirements={"merge_manifests": True}, ) - + # Mock the merge_stacks method to return known result - with patch.object(self.processor, 'merge_stacks') as mock_merge: + with patch.object(self.processor, "merge_stacks") as mock_merge: mock_merge.return_value = {"test": "merged", "merged": True} - + # Call the method self.processor.handle_stack_analyzed(test_event) - + # Verify merge was called mock_merge.assert_called_once_with({}, {"test": "original"}) - + # Verify processed event was published self.event_bus.publish_async.assert_called_once() published_event = self.event_bus.publish_async.call_args[0][0] - + # Verify the published event is correct self.assertIsInstance(published_event, StackProcessedEvent) self.assertEqual(published_event.stack_payload, {"test": "merged", "merged": True}) self.assertEqual(published_event.original_payload, {"test": "original"}) self.assertEqual(published_event.processing_applied, ["merge_manifests"]) - + def test_handle_stack_analyzed_with_expression_processing(self): """Test that expression processing is applied and results flow through.""" - + # Create test event with expression processing requirement test_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -67,32 +67,34 @@ def test_handle_stack_analyzed_with_expression_processing(self): stack_name="test_stack", action="start", stack_payload={"command": "$(env HOME)/test"}, - processing_requirements={"resolve_expressions": True} + processing_requirements={"resolve_expressions": True}, ) - + # Mock the resolve_expressions method to return known result - with patch.object(self.processor, 'resolve_expressions') as mock_resolve: + with patch.object(self.processor, "resolve_expressions") as mock_resolve: mock_resolve.return_value = '{"command": "/home/user/test", "resolved": true}' - + # Call the method self.processor.handle_stack_analyzed(test_event) - + # Verify resolve_expressions was called mock_resolve.assert_called_once_with('{"command": "$(env HOME)/test"}') - + # Verify processed event was published self.event_bus.publish_async.assert_called_once() published_event = self.event_bus.publish_async.call_args[0][0] - + # Verify the published event is correct self.assertIsInstance(published_event, StackProcessedEvent) - self.assertEqual(published_event.stack_payload, {"command": "/home/user/test", "resolved": True}) + self.assertEqual( + published_event.stack_payload, {"command": "/home/user/test", "resolved": True} + ) self.assertEqual(published_event.original_payload, {"command": "$(env HOME)/test"}) self.assertEqual(published_event.processing_applied, ["resolve_expressions"]) - + def test_handle_stack_analyzed_with_both_processing(self): """Test that both merge and expression processing are applied sequentially.""" - + # Create test event with both processing requirements test_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -101,36 +103,41 @@ def test_handle_stack_analyzed_with_both_processing(self): stack_name="test_stack", action="start", stack_payload={"command": "$(env HOME)/test"}, - processing_requirements={"merge_manifests": True, "resolve_expressions": True} + processing_requirements={"merge_manifests": True, "resolve_expressions": True}, ) - + # Mock both processing methods - with patch.object(self.processor, 'merge_stacks') as mock_merge, \ - patch.object(self.processor, 'resolve_expressions') as mock_resolve: - + with ( + patch.object(self.processor, "merge_stacks") as mock_merge, + patch.object(self.processor, "resolve_expressions") as mock_resolve, + ): mock_merge.return_value = {"command": "$(env HOME)/test", "merged": True} - mock_resolve.return_value = '{"command": "/home/user/test", "merged": true, "resolved": true}' - + mock_resolve.return_value = ( + '{"command": "/home/user/test", "merged": true, "resolved": true}' + ) + # Call the method self.processor.handle_stack_analyzed(test_event) - + # Verify both processing methods were called in order mock_merge.assert_called_once_with({}, {"command": "$(env HOME)/test"}) mock_resolve.assert_called_once_with('{"command": "$(env HOME)/test", "merged": true}') - + # Verify processed event was published self.event_bus.publish_async.assert_called_once() published_event = self.event_bus.publish_async.call_args[0][0] - + # Verify the published event has both processing results self.assertIsInstance(published_event, StackProcessedEvent) expected_payload = {"command": "/home/user/test", "merged": True, "resolved": True} self.assertEqual(published_event.stack_payload, expected_payload) - self.assertEqual(published_event.processing_applied, ["merge_manifests", "resolve_expressions"]) - + self.assertEqual( + published_event.processing_applied, ["merge_manifests", "resolve_expressions"] + ) + def test_handle_stack_analyzed_no_processing_required(self): """Test that no processing event is emitted when no processing is required.""" - + # Create test event with no processing requirements test_event = StackAnalyzedEvent( event_type=EventType.STACK_ANALYZED, @@ -139,15 +146,15 @@ def test_handle_stack_analyzed_no_processing_required(self): stack_name="test_stack", action="start", stack_payload={"test": "data"}, - processing_requirements={} + processing_requirements={}, ) - + # Call the method self.processor.handle_stack_analyzed(test_event) - + # Verify no processing event was published self.event_bus.publish_async.assert_not_called() if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/test_stack_json_pipeline_fix.py b/test/test_stack_json_pipeline_fix.py index 737b797..53c7331 100644 --- a/test/test_stack_json_pipeline_fix.py +++ b/test/test_stack_json_pipeline_fix.py @@ -2,153 +2,146 @@ """ Unit test for stack/json pipeline launch issue regression. -Tests the fix for the bug where stack/json content would not launch properly +Tests the fix for the bug where stack/json content would not launch properly through the pipeline due to incorrect name extraction in Pipeline.toStackManifest(). Bug Details: - Original Issue: Pipeline.toStackManifest() expected 'name' at root level -- Stack Format: stack/json has 'metadata.name' structure +- Stack Format: stack/json has 'metadata.name' structure - Fix: Updated toStackManifest() to check metadata.name first, fallback to root name - Result: Stack manifest correctly flows through compose → launch pipeline services Test data is embedded directly in the test to avoid file dependencies. """ -import unittest import json +import unittest + import rclpy -from rclpy.node import Node from muto_msgs.msg import StackManifest from muto_msgs.srv import ComposePlugin, LaunchPlugin +from rclpy.node import Node class TestStackJsonPipelineFix(unittest.TestCase): """Test case for stack/json pipeline manifest flow fix""" - + @classmethod def setUpClass(cls): rclpy.init() - cls.node = Node('test_stack_json_pipeline') - + cls.node = Node("test_stack_json_pipeline") + # Embedded test stack data (instead of reading from file) cls.stack_data = { "metadata": { "name": "Muto Simple Talker-Listener Stack", "description": "A simple talker-listener stack example using demo_nodes_cpp package.", - "content_type": "stack/json" + "content_type": "stack/json", }, "launch": { "node": [ - { - "name": "talker", - "pkg": "demo_nodes_cpp", - "exec": "talker" - }, - { - "name": "listener", - "pkg": "demo_nodes_cpp", - "exec": "listener" - } + {"name": "talker", "pkg": "demo_nodes_cpp", "exec": "talker"}, + {"name": "listener", "pkg": "demo_nodes_cpp", "exec": "listener"}, ] - } + }, } - - @classmethod + + @classmethod def tearDownClass(cls): cls.node.destroy_node() rclpy.shutdown() - + def test_stack_manifest_name_extraction(self): """Test that toStackManifest correctly extracts name from metadata.name""" # Test the fixed toStackManifest logic stack_msg = StackManifest() - + # This is the fixed logic from Pipeline.toStackManifest() if isinstance(self.stack_data, dict): - if 'metadata' in self.stack_data and 'name' in self.stack_data['metadata']: - stack_msg.name = self.stack_data['metadata']['name'] + if "metadata" in self.stack_data and "name" in self.stack_data["metadata"]: + stack_msg.name = self.stack_data["metadata"]["name"] else: stack_msg.name = self.stack_data.get("name", "") stack_msg.stack = json.dumps(self.stack_data) - + # Assertions self.assertEqual(stack_msg.name, "Muto Simple Talker-Listener Stack") self.assertGreater(len(stack_msg.stack), 0) self.assertIn("metadata", stack_msg.stack) self.assertIn("launch", stack_msg.stack) - + def test_stack_manifest_fallback_to_root_name(self): """Test that toStackManifest falls back to root name if metadata.name not present""" # Test with old format (name at root) old_format_stack = {"name": "Root Name Stack", "content": "test"} - + stack_msg = StackManifest() if isinstance(old_format_stack, dict): - if 'metadata' in old_format_stack and 'name' in old_format_stack['metadata']: - stack_msg.name = old_format_stack['metadata']['name'] + if "metadata" in old_format_stack and "name" in old_format_stack["metadata"]: + stack_msg.name = old_format_stack["metadata"]["name"] else: stack_msg.name = old_format_stack.get("name", "") stack_msg.stack = json.dumps(old_format_stack) - + self.assertEqual(stack_msg.name, "Root Name Stack") - + def test_compose_service_integration(self): """Test that muto_compose service works with fixed stack manifest""" # Skip if service not available (for CI environments) - compose_client = self.node.create_client(ComposePlugin, 'muto_compose') + compose_client = self.node.create_client(ComposePlugin, "muto_compose") if not compose_client.wait_for_service(timeout_sec=2.0): self.skipTest("muto_compose service not available") - + # Create stack manifest with fixed logic stack_msg = StackManifest() if isinstance(self.stack_data, dict): - if 'metadata' in self.stack_data and 'name' in self.stack_data['metadata']: - stack_msg.name = self.stack_data['metadata']['name'] + if "metadata" in self.stack_data and "name" in self.stack_data["metadata"]: + stack_msg.name = self.stack_data["metadata"]["name"] else: stack_msg.name = self.stack_data.get("name", "") stack_msg.stack = json.dumps(self.stack_data) - + # Test service call req = ComposePlugin.Request() req.input.current = stack_msg req.start = True - + future = compose_client.call_async(req) rclpy.spin_until_future_complete(self.node, future) - + self.assertIsNotNone(future.result()) response = future.result() self.assertTrue(response.success) self.assertEqual(response.output.current.name, stack_msg.name) - + def test_launch_service_integration(self): """Test that muto_apply_stack service works with fixed stack manifest""" - # Skip if service not available (for CI environments) - launch_client = self.node.create_client(LaunchPlugin, 'muto_apply_stack') + # Skip if service not available (for CI environments) + launch_client = self.node.create_client(LaunchPlugin, "muto_apply_stack") if not launch_client.wait_for_service(timeout_sec=2.0): self.skipTest("muto_apply_stack service not available") - + # Create stack manifest with fixed logic stack_msg = StackManifest() if isinstance(self.stack_data, dict): - if 'metadata' in self.stack_data and 'name' in self.stack_data['metadata']: - stack_msg.name = self.stack_data['metadata']['name'] + if "metadata" in self.stack_data and "name" in self.stack_data["metadata"]: + stack_msg.name = self.stack_data["metadata"]["name"] else: stack_msg.name = self.stack_data.get("name", "") stack_msg.stack = json.dumps(self.stack_data) - + # Test service call req = LaunchPlugin.Request() req.input.current = stack_msg req.start = True - - Editfuture = launch_client.call_async(req) + + launch_client.call_async(req) # rclpy.spin_until_future_complete(self.node, future) - + self.assertIsNotNone(future.result()) response = future.result() self.assertTrue(response.success) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/test/test_stack_manager.py b/test/test_stack_manager.py index 65c6700..f2cfadf 100644 --- a/test/test_stack_manager.py +++ b/test/test_stack_manager.py @@ -13,138 +13,113 @@ import unittest from unittest.mock import MagicMock, patch -from composer.events import EventBus, EventType, StackRequestEvent -from composer.subsystems.stack_manager import ( - StackManager, StackAnalyzer, StackProcessor, StackStateManager, - StackType, ExecutionRequirements + +from muto_composer.events import EventBus, EventType, StackRequestEvent +from muto_composer.subsystems.stack_manager import ( + StackAnalyzer, + StackManager, + StackProcessor, + StackStateManager, + StackType, ) class TestStackAnalyzer(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() self.analyzer = StackAnalyzer(self.event_bus, self.logger) - + def test_analyze_archive_stack_type(self): """Test detection of archive stack type.""" - stack = { - "metadata": { - "content_type": "stack/archive" - } - } - + stack = {"metadata": {"content_type": "stack/archive"}} + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.ARCHIVE) - + def test_analyze_json_stack_type(self): """Test detection of JSON stack type.""" - stack = { - "metadata": { - "content_type": "stack/json" - } - } - + stack = {"metadata": {"content_type": "stack/json"}} + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.JSON) - + def test_analyze_raw_stack_type(self): """Test detection of raw stack type.""" - stack = { - "node": ["test_node"], - "composable": ["test_composable"] - } - + stack = {"node": ["test_node"], "composable": ["test_composable"]} + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.RAW) - + def test_analyze_legacy_stack_type(self): """Test detection of legacy stack type.""" stack = { "launch_description_source": "test.launch.py", "on_start": "start_command", - "on_kill": "kill_command" + "on_kill": "kill_command", } - + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.LEGACY) - + def test_analyze_legacy_archive_stack_type(self): """Test backward compatibility for legacy archive content type.""" - stack = { - "metadata": { - "content_type": "archive" - } - } - + stack = {"metadata": {"content_type": "archive"}} + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.ARCHIVE) - + def test_analyze_legacy_json_stack_type(self): """Test backward compatibility for legacy JSON content type.""" - stack = { - "metadata": { - "content_type": "json" - } - } - + stack = {"metadata": {"content_type": "json"}} + stack_type = self.analyzer.analyze_stack_type(stack) self.assertEqual(stack_type, StackType.JSON) - + def test_determine_archive_execution_requirements(self): """Test execution requirements for archive stack.""" - stack = { - "metadata": {"content_type": "stack/archive"}, - "node": ["test_node"] - } - + stack = {"metadata": {"content_type": "stack/archive"}, "node": ["test_node"]} + requirements = self.analyzer.determine_execution_requirements(stack) - + self.assertTrue(requirements.requires_provision) self.assertTrue(requirements.requires_launch) self.assertTrue(requirements.has_nodes) self.assertFalse(requirements.has_composables) - + def test_determine_json_execution_requirements(self): """Test execution requirements for JSON stack.""" - stack = { - "metadata": {"content_type": "stack/json"}, - "composable": ["test_composable"] - } - + stack = {"metadata": {"content_type": "stack/json"}, "composable": ["test_composable"]} + requirements = self.analyzer.determine_execution_requirements(stack) - + self.assertFalse(requirements.requires_provision) self.assertTrue(requirements.requires_launch) self.assertFalse(requirements.has_nodes) self.assertTrue(requirements.has_composables) - + def test_handle_stack_request_event(self): """Test handling of stack request event.""" # Create mock event handler to capture published events published_events = [] - + def capture_event(event): published_events.append(event) - + self.event_bus.subscribe(EventType.STACK_ANALYZED, capture_event) - + # Create stack request event request_event = StackRequestEvent( event_type=EventType.STACK_REQUEST, source_component="test", stack_name="test_stack", action="start", - stack_payload={ - "metadata": {"content_type": "stack/archive"}, - "node": ["test_node"] - } + stack_payload={"metadata": {"content_type": "stack/archive"}, "node": ["test_node"]}, ) - + # Handle the event self.analyzer.handle_stack_request(request_event) - + # Verify analyzed event was published self.assertEqual(len(published_events), 1) analyzed_event = published_events[0] @@ -168,9 +143,7 @@ def capture_event(event): source_component="test", stack_name="test_stack", action="kill", - stack_payload={ - "stackId": "org.eclipse.muto.sandbox:talker_listener" - } + stack_payload={"stackId": "org.eclipse.muto.sandbox:talker_listener"}, ) # Handle the event - should not fail validation @@ -182,7 +155,9 @@ def capture_event(event): self.assertEqual(analyzed_event.action, "kill") self.assertEqual(analyzed_event.analysis_result["stack_type"], "kill") self.assertTrue(analyzed_event.analysis_result["is_kill_action"]) - self.assertEqual(analyzed_event.analysis_result["stack_id"], "org.eclipse.muto.sandbox:talker_listener") + self.assertEqual( + analyzed_event.analysis_result["stack_id"], "org.eclipse.muto.sandbox:talker_listener" + ) self.assertFalse(analyzed_event.processing_requirements["requires_provision"]) self.assertFalse(analyzed_event.processing_requirements["requires_launch"]) @@ -205,8 +180,8 @@ def capture_event(event): "topic": "org.eclipse.muto.sandbox/ibo-test-vehicle/things/live/messages/stack/commands/kill", "headers": {"content-type": "application/json"}, "path": "/inbox/messages/stack/commands/kill", - "value": {"stackId": "org.eclipse.muto.sandbox:talker_listener"} - } + "value": {"stackId": "org.eclipse.muto.sandbox:talker_listener"}, + }, ) # Handle the event - should not fail validation @@ -218,7 +193,9 @@ def capture_event(event): self.assertEqual(analyzed_event.action, "kill") self.assertEqual(analyzed_event.analysis_result["stack_type"], "kill") self.assertTrue(analyzed_event.analysis_result["is_kill_action"]) - self.assertEqual(analyzed_event.analysis_result["stack_id"], "org.eclipse.muto.sandbox:talker_listener") + self.assertEqual( + analyzed_event.analysis_result["stack_id"], "org.eclipse.muto.sandbox:talker_listener" + ) self.assertTrue(analyzed_event.processing_requirements["is_kill_action"]) def test_handle_kill_action_without_stack_id(self): @@ -236,7 +213,7 @@ def capture_event(event): source_component="test", stack_name="test_stack", action="kill", - stack_payload={} + stack_payload={}, ) # Handle the event - should fail due to missing stackId @@ -248,17 +225,16 @@ def capture_event(event): class TestStackProcessor(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() - + # Mock the stack parser - with patch('composer.subsystems.stack_manager.create_stack_parser') as mock_parser: + with patch("muto_composer.subsystems.stack_manager.create_stack_parser") as mock_parser: mock_parser.return_value = MagicMock() self.processor = StackProcessor(self.event_bus, self.logger) - - @patch('composer.subsystems.stack_manager.Stack') + + @patch("muto_composer.subsystems.stack_manager.Stack") def test_merge_stacks(self, mock_stack_class): """Test stack merging functionality.""" # Setup mock Stack behavior @@ -266,136 +242,134 @@ def test_merge_stacks(self, mock_stack_class): mock_next_stack = MagicMock() mock_merged = MagicMock() mock_merged.manifest = {"merged": "stack"} - + mock_stack_class.side_effect = [mock_current_stack, mock_next_stack] mock_current_stack.merge.return_value = mock_merged - + # Test merge current = {"current": "stack"} next_stack = {"next": "stack"} - + result = self.processor.merge_stacks(current, next_stack) - + # Verify Stack objects were created correctly mock_stack_class.assert_any_call(manifest=current) mock_stack_class.assert_any_call(manifest=next_stack) - + # Verify merge was called mock_current_stack.merge.assert_called_once_with(mock_next_stack) - + # Verify result self.assertEqual(result, {"merged": "stack"}) - + def test_resolve_expressions_basic(self): """Test basic expression resolution.""" stack_json = '{"command": "$(env HOME)/test"}' - - with patch('os.getenv', return_value='/home/user'): + + with patch("os.getenv", return_value="/home/user"): result = self.processor.resolve_expressions(stack_json) - + # Should resolve the environment variable - self.assertIn('/home/user/test', result) - self.assertNotIn('$(env HOME)', result) - - @patch('composer.subsystems.stack_manager.get_package_share_directory') + self.assertIn("/home/user/test", result) + self.assertNotIn("$(env HOME)", result) + + @patch("muto_composer.subsystems.stack_manager.get_package_share_directory") def test_resolve_expressions_find(self, mock_get_package): """Test find expression resolution.""" - mock_get_package.return_value = '/opt/ros/jazzy/share/test_package' - + mock_get_package.return_value = "/opt/ros/jazzy/share/test_package" + stack_json = '{"path": "$(find test_package)/config"}' - + result = self.processor.resolve_expressions(stack_json) - + # Should resolve the package path - self.assertIn('/opt/ros/jazzy/share/test_package/config', result) - self.assertNotIn('$(find test_package)', result) - + self.assertIn("/opt/ros/jazzy/share/test_package/config", result) + self.assertNotIn("$(find test_package)", result) + def test_parse_payload(self): """Test payload parsing.""" payload = {"test": "data"} - + # Mock the stack parser self.processor.stack_parser.parse_payload.return_value = {"parsed": "data"} - + result = self.processor.parse_payload(payload) - + self.assertEqual(result, {"parsed": "data"}) self.processor.stack_parser.parse_payload.assert_called_once_with(payload) class TestStackStateManager(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() self.state_manager = StackStateManager(self.event_bus, self.logger) - + def test_set_and_get_current_stack(self): """Test setting and getting current stack.""" test_stack = {"test": "stack"} - + self.state_manager.set_current_stack(test_stack) result = self.state_manager.get_current_stack() - + self.assertEqual(result, test_stack) - + def test_set_and_get_next_stack(self): """Test setting and getting next stack.""" test_stack = {"next": "stack"} - + self.state_manager.set_next_stack(test_stack) result = self.state_manager.get_next_stack() - + self.assertEqual(result, test_stack) - + def test_get_stack_transition_initial_deploy(self): """Test transition type determination for initial deploy.""" self.state_manager.set_next_stack({"next": "stack"}) - + transition = self.state_manager.get_stack_transition() - + self.assertEqual(transition.transition_type, "initial_deploy") self.assertIsNone(transition.current) self.assertEqual(transition.next, {"next": "stack"}) - + def test_get_stack_transition_update(self): """Test transition type determination for update.""" self.state_manager.set_current_stack({"current": "stack"}) self.state_manager.set_next_stack({"next": "stack"}) - + transition = self.state_manager.get_stack_transition() - + self.assertEqual(transition.transition_type, "update") self.assertEqual(transition.current, {"current": "stack"}) self.assertEqual(transition.next, {"next": "stack"}) class TestStackManager(unittest.TestCase): - def setUp(self): self.event_bus = EventBus() self.logger = MagicMock() - + # Mock the dependencies - with patch('composer.subsystems.stack_manager.create_stack_parser'): + with patch("muto_composer.subsystems.stack_manager.create_stack_parser"): self.stack_manager = StackManager(self.event_bus, self.logger) - + def test_initialization(self): """Test StackManager initialization.""" self.assertIsNotNone(self.stack_manager.state_manager) self.assertIsNotNone(self.stack_manager.analyzer) self.assertIsNotNone(self.stack_manager.processor) - + def test_get_components(self): """Test getting individual components.""" state_manager = self.stack_manager.get_state_manager() analyzer = self.stack_manager.get_analyzer() processor = self.stack_manager.get_processor() - + self.assertIsNotNone(state_manager) self.assertIsNotNone(analyzer) self.assertIsNotNone(processor) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/test/test_talker_listener_samples.py b/test/test_talker_listener_samples.py index 2a6ec76..f3b84be 100644 --- a/test/test_talker_listener_samples.py +++ b/test/test_talker_listener_samples.py @@ -18,11 +18,10 @@ """ import json -import os import unittest from pathlib import Path -from composer.utils.stack_parser import StackParser, create_stack_parser +from muto_composer.utils.stack_parser import create_stack_parser class TestTalkerListenerSamples(unittest.TestCase): @@ -39,7 +38,7 @@ def _load_sample(self, filename: str) -> dict: """Load a sample JSON file.""" sample_path = self.samples_dir / filename self.assertTrue(sample_path.exists(), f"Sample file {filename} not found at {sample_path}") - with open(sample_path, 'r') as f: + with open(sample_path) as f: return json.load(f) def test_talker_listener_json_format(self): @@ -83,8 +82,7 @@ def test_talker_listener_json_validation(self): parsed = self.parser.parse_payload(payload) self.assertTrue( - self.parser.validate_stack(parsed), - "Talker-listener JSON stack should pass validation" + self.parser.validate_stack(parsed), "Talker-listener JSON stack should pass validation" ) def test_talker_listener_archive_format(self): @@ -124,7 +122,7 @@ def test_talker_listener_archive_validation(self): self.assertTrue( self.parser.validate_stack(parsed), - "Talker-listener archive stack should pass validation" + "Talker-listener archive stack should pass validation", ) def test_talker_listener_json_instance_format(self): @@ -167,13 +165,12 @@ def test_all_samples_are_valid_json(self): self.assertGreater(len(sample_files), 0, "Should have sample files") for sample_file in sample_files: - with self.subTest(file=sample_file.name): - with open(sample_file, 'r') as f: - try: - data = json.load(f) - self.assertIsInstance(data, dict) - except json.JSONDecodeError as e: - self.fail(f"Invalid JSON in {sample_file.name}: {e}") + with self.subTest(file=sample_file.name), open(sample_file) as f: + try: + data = json.load(f) + self.assertIsInstance(data, dict) + except json.JSONDecodeError as e: + self.fail(f"Invalid JSON in {sample_file.name}: {e}") def test_topology_nodes_are_consistent(self): """ @@ -224,9 +221,7 @@ def test_validate_stack_returns_false_for_empty_dict(self): def test_validate_stack_returns_true_for_valid_stack(self): """Test validation returns true for valid stack with nodes.""" - stack = { - "node": [{"name": "test", "pkg": "test_pkg", "exec": "test_exec"}] - } + stack = {"node": [{"name": "test", "pkg": "test_pkg", "exec": "test_exec"}]} self.assertTrue(self.parser.validate_stack(stack)) diff --git a/test/test_traverser.py b/test/test_traverser.py index 20d2a4f..e0d6a6b 100644 --- a/test/test_traverser.py +++ b/test/test_traverser.py @@ -13,16 +13,16 @@ import unittest +from launch import LaunchContext +from launch.actions import DeclareLaunchArgument, GroupAction +from launch.substitutions import LaunchConfiguration, TextSubstitution from launch_ros.actions import Node from launch_ros.descriptions import ComposableNode -from composer.introspection.traverser import ( +from muto_composer.introspection.traverser import ( recursively_extract_entities, resolve_substitutions, ) -from launch import LaunchContext -from launch.actions import DeclareLaunchArgument, GroupAction -from launch.substitutions import LaunchConfiguration, TextSubstitution class TestResolveSubstitutions(unittest.TestCase): @@ -67,9 +67,7 @@ def test_simple_node_extraction(self): self.assertIsInstance(self.nodes[0], Node) def test_composable_node_extraction(self): - cnode = ComposableNode( - package="test_pkg", plugin="test_plugin", name="test_node" - ) + cnode = ComposableNode(package="test_pkg", plugin="test_plugin", name="test_node") recursively_extract_entities( [cnode], self.context, self.nodes, self.composable_nodes, self.containers ) diff --git a/test/test_watchdog.py b/test/test_watchdog.py index 02660e5..3a86cb7 100644 --- a/test/test_watchdog.py +++ b/test/test_watchdog.py @@ -16,11 +16,11 @@ """ import json -import unittest -from unittest.mock import MagicMock, patch import time +import unittest +from unittest.mock import patch -from composer.subsystems.watchdog import ( +from muto_composer.subsystems.watchdog import ( ComposerWatchdog, HealthStatus, SubsystemHealth, @@ -63,7 +63,7 @@ def test_to_dict(self): status=HealthStatus.HEALTHY, message="All good", last_check=1234567890.0, - response_time_ms=50.5 + response_time_ms=50.5, ) result = health.to_dict() @@ -89,11 +89,9 @@ def test_to_dict(self): """Test to_dict serialization.""" report = SystemHealthReport( overall_status=HealthStatus.HEALTHY, - subsystems={ - "test": SubsystemHealth(name="test", status=HealthStatus.HEALTHY) - }, + subsystems={"test": SubsystemHealth(name="test", status=HealthStatus.HEALTHY)}, timestamp=1234567890.0, - uptime_seconds=100.0 + uptime_seconds=100.0, ) result = report.to_dict() @@ -128,8 +126,8 @@ def test_expected_services_present(self): class TestComposerWatchdogInitialization(unittest.TestCase): """Tests for ComposerWatchdog initialization.""" - @patch('rclpy.init') - @patch('rclpy.shutdown') + @patch("rclpy.init") + @patch("rclpy.shutdown") def test_services_to_monitor_count(self, mock_shutdown, mock_init): """Test that all services are tracked for monitoring.""" # The SERVICES_TO_MONITOR constant should be accessible without instantiation @@ -158,18 +156,18 @@ def test_full_report_serialization(self): status=HealthStatus.HEALTHY, message="OK", last_check=time.time(), - response_time_ms=10.5 + response_time_ms=10.5, ), "service2": SubsystemHealth( name="service2", status=HealthStatus.FAILED, message="Service not found", last_check=time.time(), - response_time_ms=5.0 + response_time_ms=5.0, ), }, timestamp=time.time(), - uptime_seconds=3600.0 + uptime_seconds=3600.0, ) # Should not raise @@ -202,10 +200,7 @@ def test_all_healthy_is_healthy(self): degraded_count = 0 if failed_count > 0: - if healthy_count > 0: - status = HealthStatus.DEGRADED - else: - status = HealthStatus.FAILED + status = HealthStatus.DEGRADED if healthy_count > 0 else HealthStatus.FAILED elif degraded_count > 0: status = HealthStatus.DEGRADED else: @@ -220,10 +215,7 @@ def test_some_failed_is_degraded(self): degraded_count = 0 if failed_count > 0: - if healthy_count > 0: - status = HealthStatus.DEGRADED - else: - status = HealthStatus.FAILED + status = HealthStatus.DEGRADED if healthy_count > 0 else HealthStatus.FAILED elif degraded_count > 0: status = HealthStatus.DEGRADED else: @@ -238,10 +230,7 @@ def test_all_failed_is_failed(self): degraded_count = 0 if failed_count > 0: - if healthy_count > 0: - status = HealthStatus.DEGRADED - else: - status = HealthStatus.FAILED + status = HealthStatus.DEGRADED if healthy_count > 0 else HealthStatus.FAILED elif degraded_count > 0: status = HealthStatus.DEGRADED else: