diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index d2401d9..10e491e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -35,3 +35,15 @@ jobs: environments: default - run: pixi run --locked test - run: pixi run --locked package + - run: pixi run --locked typecheck + + visualization: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: prefix-dev/setup-pixi@v0.10.2 + with: + pixi-version: v0.79.0 + cache: true + environments: visualization + - run: pixi run --locked -e visualization typecheck-gui diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0de83dc..36989a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,8 @@ contains only code quality tools. ```sh pixi run --locked test pixi run --locked package +pixi run --locked typecheck +pixi run --locked -e visualization typecheck-gui pixi run --locked -e format format pixi run --locked -e format lint ``` @@ -35,3 +37,9 @@ checks. Changes to scheduling, action outcomes, error recovery, parameter syntax, or registration should describe compatibility and migration. Use issues to compare options before introducing a new public contract. + +The core, visualization, and packaging checks use strict mypy. Public packages +ship `py.typed`; blackboards are generic and parameter values use `object` to +reflect the existing YAML and ROS inputs. Narrow a parameter with `isinstance` +before using it as a particular type. Untyped ROS integrations are confined to +third-party imports, and the small RQT stubs document the interfaces used here. diff --git a/dynamic_stack_decider/dynamic_stack_decider/__init__.py b/dynamic_stack_decider/dynamic_stack_decider/__init__.py index 3ad5446..f92bb55 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/__init__.py +++ b/dynamic_stack_decider/dynamic_stack_decider/__init__.py @@ -2,4 +2,4 @@ from .abstract_decision_element import AbstractDecisionElement from .dsd import DSD -__all__ = [DSD, AbstractDecisionElement, AbstractActionElement] +__all__ = ["DSD", "AbstractDecisionElement", "AbstractActionElement"] diff --git a/dynamic_stack_decider/dynamic_stack_decider/abstract_action_element.py b/dynamic_stack_decider/dynamic_stack_decider/abstract_action_element.py index b3f8bed..2ff036c 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/abstract_action_element.py +++ b/dynamic_stack_decider/dynamic_stack_decider/abstract_action_element.py @@ -1,13 +1,16 @@ -from abc import ABCMeta +from __future__ import annotations + +from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING from dynamic_stack_decider.abstract_stack_element import AbstractStackElement +from dynamic_stack_decider.types import BlackboardT, ElementData, Parameters if TYPE_CHECKING: from dynamic_stack_decider.dsd import DSD -class AbstractActionElement(AbstractStackElement, metaclass=ABCMeta): +class AbstractActionElement(AbstractStackElement[BlackboardT], metaclass=ABCMeta): """ One action is similar to a state of an FSM. As in this case, the system stays in this state in contrast to the decision elements which are called only for determining the active action. @@ -18,7 +21,7 @@ class AbstractActionElement(AbstractStackElement, metaclass=ABCMeta): If the action is complete, it can remove itself from the stack by performing a pop command. """ - def __init__(self, blackboard, dsd: "DSD", parameters: dict[str, bool | int | float | str]): + def __init__(self, blackboard: BlackboardT, dsd: DSD[BlackboardT], parameters: Parameters) -> None: """ Constructor of the action element :param blackboard: Shared blackboard for data exchange between elements @@ -29,14 +32,14 @@ def __init__(self, blackboard, dsd: "DSD", parameters: dict[str, bool | int | fl # Reevaluation can be disabled by setting 'r' or 'reevaluate' to False self.never_reevaluate = not parameters.get("r", True) or not parameters.get("reevaluate", True) - def do_not_reevaluate(self): + def do_not_reevaluate(self) -> None: """ Prohibits the next reevaluate. This is useful if you have an action which has to be completed without interruption, e.g. a kick animation. """ self._dsd.set_do_not_reevaluate() - def repr_dict(self) -> dict: + def repr_dict(self) -> ElementData: """ Represent this stack element as dictionary which is JSON encodable """ @@ -45,3 +48,8 @@ def repr_dict(self) -> dict: "name": self.name, "debug_data": self._debug_data, } + + @abstractmethod + def perform(self, reevaluate: bool = False) -> None: + """Execute the element; decisions return an outcome and actions return nothing.""" + raise NotImplementedError(f"Implement perform() in {self.name}") diff --git a/dynamic_stack_decider/dynamic_stack_decider/abstract_decision_element.py b/dynamic_stack_decider/dynamic_stack_decider/abstract_decision_element.py index 7d53757..77ddc97 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/abstract_decision_element.py +++ b/dynamic_stack_decider/dynamic_stack_decider/abstract_decision_element.py @@ -1,9 +1,12 @@ -from abc import ABCMeta +from __future__ import annotations + +from abc import ABCMeta, abstractmethod from dynamic_stack_decider.abstract_stack_element import AbstractStackElement +from dynamic_stack_decider.types import BlackboardT, ElementData -class AbstractDecisionElement(AbstractStackElement, metaclass=ABCMeta): +class AbstractDecisionElement(AbstractStackElement[BlackboardT], metaclass=ABCMeta): """ The logic is encapsulated in two types of elements. The decision elements define the logical path similar to a behavior tree. @@ -20,7 +23,7 @@ class AbstractDecisionElement(AbstractStackElement, metaclass=ABCMeta): _reevaluate = False - def repr_dict(self) -> dict: + def repr_dict(self) -> ElementData: """ Represent this stack element as dictionary which is JSON encodable """ @@ -30,7 +33,7 @@ def repr_dict(self) -> dict: "debug_data": self._debug_data, } - def get_reevaluate(self): + def get_reevaluate(self) -> bool: """ Each decision element may define a \textit{reevaluate} criteria. If the corresponding method returns true, the element will be executed even if it is in the middle of the stack. @@ -45,3 +48,8 @@ def get_reevaluate(self): This can be used to regularly check preconditions, e.g. if the robot knows where the ball is. """ return self._reevaluate + + @abstractmethod + def perform(self, reevaluate: bool = False) -> str: + """Execute the element; decisions return an outcome and actions return nothing.""" + raise NotImplementedError(f"Implement perform() in {self.name}") diff --git a/dynamic_stack_decider/dynamic_stack_decider/abstract_stack_element.py b/dynamic_stack_decider/dynamic_stack_decider/abstract_stack_element.py index d60081c..8832310 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/abstract_stack_element.py +++ b/dynamic_stack_decider/dynamic_stack_decider/abstract_stack_element.py @@ -1,13 +1,16 @@ +from __future__ import annotations + from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic from dynamic_stack_decider.logger import get_logger +from dynamic_stack_decider.types import BlackboardT, ElementData, Parameters if TYPE_CHECKING: from dynamic_stack_decider.dsd import DSD -class AbstractStackElement(metaclass=ABCMeta): +class AbstractStackElement(Generic[BlackboardT], metaclass=ABCMeta): """ The AbstractStackElement is the basis of all elements on the stack. It provides some help functions which should not be overloaded. @@ -15,16 +18,16 @@ class AbstractStackElement(metaclass=ABCMeta): Each element which inherits from the AbstractStackElement can be used as a root element on the stack. """ - _dsd: "DSD" - parameters: dict[str, bool | int | float | str] + _dsd: DSD[BlackboardT] + parameters: Parameters - def __init__(self, blackboard, dsd: "DSD", parameters: dict[str, bool | int | float | str]): + def __init__(self, blackboard: BlackboardT, dsd: DSD[BlackboardT], parameters: Parameters) -> None: """ :param blackboard: Shared blackboard for data exchange between elements :param dsd: The stack decider which has this element on its stack. :param parameters: Parameters which serve as arguments to this element """ - self._debug_data = {} + self._debug_data: dict[str, object] = {} """ This is a dict in which data can be saved that should get represented on a __repr__ call. It can be set using publish_debug_data() @@ -41,7 +44,7 @@ def name(self) -> str: """ return self.__class__.__name__ - def pop(self): + def pop(self) -> None: """ Help method which pops the element of the stack. @@ -52,7 +55,7 @@ def pop(self): """ self._dsd.pop() - def on_pop(self): # noqa + def on_pop(self) -> None: """ This method is called when the element is popped from the stack. It can be used to clean up resources, cancel actions or similar tasks. @@ -61,7 +64,7 @@ def on_pop(self): # noqa pass @abstractmethod - def perform(self, reevaluate=False): + def perform(self, reevaluate: bool = False) -> str | None: """ This method is called when the element is on top of the stack. This method has to be overloaded by the implementation! @@ -71,13 +74,13 @@ def perform(self, reevaluate=False): msg = f"You should override perform() in {self.__class__.__name__}" raise NotImplementedError(msg) - def interrupt(self): + def interrupt(self) -> None: """ An interrupt leads to a complete clearing of the stack. """ self._dsd.interrupt() - def publish_debug_data(self, label: str, data: dict | list | int | float | str | bool): + def publish_debug_data(self, label: str, data: object) -> None: """ Publish debug data. Can be viewed using the DSD visualization @@ -97,7 +100,7 @@ def publish_debug_data(self, label: str, data: dict | list | int | float | str | get_logger().debug(f"{label}: {data}") self._debug_data[label] = data - def clear_debug_data(self): + def clear_debug_data(self) -> None: """ Clear existing debug data @@ -105,7 +108,7 @@ def clear_debug_data(self): """ self._debug_data = {} - def repr_dict(self) -> dict: + def repr_dict(self) -> ElementData: """Represent this stack element as dictionary which is JSON encodable""" return { "type": "abstract", diff --git a/dynamic_stack_decider/dynamic_stack_decider/dsd.py b/dynamic_stack_decider/dynamic_stack_decider/dsd.py index 11ca3ca..d96392b 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/dsd.py +++ b/dynamic_stack_decider/dynamic_stack_decider/dsd.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import importlib import inspect import json @@ -5,6 +7,7 @@ import sys import traceback from pathlib import Path +from typing import Generic from rclpy.node import Node from rclpy.qos import DurabilityPolicy, QoSProfile @@ -21,9 +24,10 @@ DecisionTreeElement, Tree, ) +from dynamic_stack_decider.types import BlackboardT, StackData -def discover_elements(path: str) -> dict[str, AbstractStackElement]: +def discover_elements(path: str | Path) -> dict[str, type[AbstractStackElement[BlackboardT]]]: """ Extract all the classes from the files in the given path and return a dictionary containing them @@ -31,11 +35,11 @@ def discover_elements(path: str) -> dict[str, AbstractStackElement]: :return: A dictionary with class names as keys and classes as values :raises ValueError: if path is not an existing directory or file """ - elements = {} + elements: dict[str, type[AbstractStackElement[BlackboardT]]] = {} path = Path(path) original_pythonpath = sys.path.copy() - def discover_module_elements(module_name): + def discover_module_elements(module_name: str) -> None: try: if sys.modules.get(module_name, False): # reload the module if it is already imported, @@ -81,7 +85,7 @@ def discover_module_elements(module_name): return elements -class DSD: +class DSD(Generic[BlackboardT]): """ One decision is defined as the root decision, the starting point. Each decision element, which is pushed on the stack, is immediately executed until no further element is pushed. @@ -100,14 +104,14 @@ class DSD: Any following code will be executed as soon as the stack is not further expanded. """ - start_element = None + start_element: AbstractTreeElement[BlackboardT] | None = None stack_exec_index = -1 stack_reevaluate = False do_not_reevaluate = False old_representation = "" debug_active_action_cache: str | None = None - def __init__(self, blackboard, debug_topic: str = None, node: Node | None = None): + def __init__(self, blackboard: BlackboardT, debug_topic: str | None = None, node: Node | None = None) -> None: """ :param blackboard: Blackboard instance which will be available to all modules :param debug_topic: Topic on which debug data should be published @@ -116,13 +120,13 @@ def __init__(self, blackboard, debug_topic: str = None, node: Node | None = None self.blackboard = blackboard self.node = node - self.tree: Tree | None = None + self.tree: Tree[BlackboardT] | None = None # The stack is implemented as a list of tuples consisting of the tree element # and the actual module instance - self.stack: list[tuple[AbstractTreeElement, AbstractStackElement]] = [] + self.stack: list[tuple[AbstractTreeElement[BlackboardT], AbstractStackElement[BlackboardT]]] = [] - self.actions: dict[str, AbstractActionElement] = {} - self.decisions: dict[str, AbstractDecisionElement] = {} + self.actions: dict[str, type[AbstractActionElement[BlackboardT]]] = {} + self.decisions: dict[str, type[AbstractDecisionElement[BlackboardT]]] = {} # Check if debugging is active self.debug_active = debug_topic is not None @@ -145,36 +149,38 @@ def __init__(self, blackboard, debug_topic: str = None, node: Node | None = None self.debug_current_action_publisher = node.create_publisher(String, debug_current_action_topic, 10) get_logger().debug(f"Debugging current action on '{debug_current_action_topic}'") - def register_actions(self, module_path): + def register_actions(self, module_path: str | Path) -> None: """ Register every class in a given path as an action :param module_path: A path containing files with classes extending AbstractActionElement """ - self.actions = {k: v for k, v in discover_elements(module_path).items() if issubclass(v, AbstractActionElement)} + discovered: dict[str, type[AbstractStackElement[BlackboardT]]] = discover_elements(module_path) + self.actions = {k: v for k, v in discovered.items() if issubclass(v, AbstractActionElement)} - def register_decisions(self, module_path): + def register_decisions(self, module_path: str | Path) -> None: """ Register every class in a given path as a decision :param module_path: A path containing files with classes extending AbstractDecisionElement """ - self.decisions = { - k: v for k, v in discover_elements(module_path).items() if issubclass(v, AbstractDecisionElement) - } + discovered: dict[str, type[AbstractStackElement[BlackboardT]]] = discover_elements(module_path) + self.decisions = {k: v for k, v in discovered.items() if issubclass(v, AbstractDecisionElement)} - def load_behavior(self, path): + def load_behavior(self, path: str | Path) -> None: """ Load a .dsd file into the behavior to execute it. This should be called after the actions and decisions have been loaded. :param path: The path to the .dsd file describing the behavior :return: """ - parser = DsdParser(self.node) + parser: DsdParser[BlackboardT] = DsdParser(self.node) self.tree = parser.parse(path) + if self.tree.root_element is None: + raise ValueError("Behavior has no root element") self._bind_modules(self.tree.root_element) self.set_start_element(self.tree.root_element) self.debug_publish_tree() - def _bind_modules(self, element): + def _bind_modules(self, element: AbstractTreeElement[BlackboardT]) -> None: """ Recursively traverse the tree and bind the registered action and decision classes to the corresponding tree elements @@ -198,19 +204,23 @@ def _bind_modules(self, element): else: raise ValueError(f'Unknown parser tree element type "{type(element)}" for element "{element}"!') - def _init_element(self, element: AbstractTreeElement) -> AbstractStackElement: + def _init_element(self, element: AbstractTreeElement[BlackboardT]) -> AbstractStackElement[BlackboardT]: """Initializes the module belonging to the given element.""" + if element.module is None: + raise ValueError(f"Element {element.name!r} has no bound implementation") return element.module(self.blackboard, self, element.parameters) - def set_start_element(self, start_element: AbstractTreeElement): + def set_start_element(self, start_element: AbstractTreeElement[BlackboardT]) -> None: """ This method defines the start element on the stack, which stays always on the bottom of the stack. It should be called in __init__. """ self.start_element = start_element + if self.start_element is None: + raise RuntimeError("No behavior has been loaded") self.stack = [(self.start_element, self._init_element(self.start_element))] - def interrupt(self): + def interrupt(self) -> None: """ An interrupt is an event which clears the complete stack to reset the behavior. In the special case of RoboCup, we use it when the game-state changes, but it can also be used for @@ -221,9 +231,11 @@ def interrupt(self): # we were currently checking preconditions # we stop this, so that update() knows that it has to stop self.stack_reevaluate = False + if self.start_element is None: + raise RuntimeError("No behavior has been loaded") self.stack = [(self.start_element, self._init_element(self.start_element))] - def update(self, reevaluate: bool = True): + def update(self, reevaluate: bool = True) -> None: """ Calls the element which is currently on top of the stack. Before doing this, all preconditions are checked (all decision elements where reevaluate is true). @@ -240,6 +252,8 @@ def update(self, reevaluate: bool = True): for tree_element, instance in self.stack[:-1]: # check all elements except the top one, but not the actions if isinstance(instance, AbstractDecisionElement) and instance.get_reevaluate(): + if not isinstance(tree_element, DecisionTreeElement): + raise TypeError("Decision instance is not bound to a decision tree element") result = instance.perform(True) # Check whether this result would be an ELSE case result_is_else = result not in tree_element.children.keys() @@ -272,13 +286,15 @@ def update(self, reevaluate: bool = True): # Deactivate reevaluation if action had never_reevaluate flag self.set_do_not_reevaluate() # Run the top module - result = current_instance.perform() + top_result = current_instance.perform() if isinstance(current_instance, AbstractDecisionElement): - self.push(current_tree_element.get_child(result)) + if not isinstance(current_tree_element, DecisionTreeElement): + raise TypeError("Decision instance is not bound to a decision tree element") + self.push(current_tree_element.get_child(top_result)) except Exception: get_logger().error(str(traceback.format_exc())) - def push(self, element: AbstractTreeElement): + def push(self, element: AbstractTreeElement[BlackboardT]) -> None: """ Put a new element on the stack and start it directly. @@ -291,7 +307,7 @@ def push(self, element: AbstractTreeElement): # we call the new element without another reevaluate self.update(False) - def pop(self): + def pop(self) -> None: """ Removes the element from the stack. The previous element will not be called again. """ @@ -325,25 +341,30 @@ def pop(self): # because no module should control the stack beyond its lifetime self.do_not_reevaluate = False - def set_do_not_reevaluate(self): + def set_do_not_reevaluate(self) -> None: """No reevaluation on next iteration""" self.do_not_reevaluate = True - def get_stack(self): + def get_stack(self) -> list[tuple[AbstractTreeElement[BlackboardT], AbstractStackElement[BlackboardT]]]: """ Returns the current stack """ return self.stack - def debug_publish_stack(self): + def debug_publish_stack(self) -> None: """ Publishes a JSON representation of the current stack """ if self.debug_active and self.debug_stack_publisher.get_subscription_count() != 0: # Construct JSON encodable object which represents the current stack - data = None + data: StackData | None = None for tree_elem, elem_instance in reversed(self.stack): - elem_data = elem_instance.repr_dict() + elem_data: StackData = { + **elem_instance.repr_dict(), + "activation_reason": tree_elem.activation_reason, + "id": id(tree_elem), + "next": data, + } elem_data["activation_reason"] = tree_elem.activation_reason # Attach the id of the corresponding tree element so the visualization can match this # stack element to the exact tree element (names alone are ambiguous within a chain). @@ -354,17 +375,19 @@ def debug_publish_stack(self): msg = String(data=json.dumps(data)) self.debug_stack_publisher.publish(msg) - def debug_publish_tree(self): + def debug_publish_tree(self) -> None: """ Publishes a JSON representation of the parsed tree """ if self.debug_active: # Construct JSON encodable object which represents the current stack + if self.tree is None: + return data = self.tree.repr_dict() msg = String(data=json.dumps(data)) self.debug_tree_publisher.publish(msg) - def debug_publish_current_action(self): + def debug_publish_current_action(self) -> None: """ Publishes the name of the currently active action """ diff --git a/dynamic_stack_decider/dynamic_stack_decider/logger.py b/dynamic_stack_decider/dynamic_stack_decider/logger.py index 81c8226..f6dfd79 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/logger.py +++ b/dynamic_stack_decider/dynamic_stack_decider/logger.py @@ -1,5 +1,8 @@ +from typing import cast + from rclpy import logging +from rclpy.impl.rcutils_logger import RcutilsLogger -def get_logger(): - return logging.get_logger("dynamic_stack_decider") +def get_logger() -> RcutilsLogger: + return cast(RcutilsLogger, logging.get_logger("dynamic_stack_decider")) diff --git a/dynamic_stack_decider/dynamic_stack_decider/parser.py b/dynamic_stack_decider/dynamic_stack_decider/parser.py index 2f6e78c..3c981c4 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/parser.py +++ b/dynamic_stack_decider/dynamic_stack_decider/parser.py @@ -1,17 +1,22 @@ +from __future__ import annotations + import copy import re +from pathlib import Path +from typing import Generic import yaml from rclpy.node import Node -from dynamic_stack_decider.tree import ActionTreeElement, DecisionTreeElement, Tree +from dynamic_stack_decider.tree import AbstractTreeElement, ActionTreeElement, DecisionTreeElement, Tree +from dynamic_stack_decider.types import BlackboardT, ParameterReferences, Parameters -class DsdParser: - def __init__(self, node: Node | None = None): +class DsdParser(Generic[BlackboardT]): + def __init__(self, node: Node | None = None) -> None: self.node = node - def parse(self, file_path: str) -> Tree: + def parse(self, file_path: str | Path) -> Tree[BlackboardT]: """ Parse a .dsd file to a Tree @@ -19,13 +24,13 @@ def parse(self, file_path: str) -> Tree: :return: a Tree object containing the parsed elements """ # The root tree - tree = Tree() + tree: Tree[BlackboardT] = Tree() # Dictionary of subtrees that are created # The key is the name and the value is the corresponding TreeElement - subtrees = dict() + subtrees: dict[str, Tree[BlackboardT]] = {} current_subtree = tree - current_tree_element = None + current_tree_element: AbstractTreeElement[BlackboardT] | None = None next_is_start = False next_is_comment = False comment = False @@ -92,6 +97,8 @@ def parse(self, file_path: str) -> Tree: if indent < last_indent: # Go layers up, depending on indent difference for _ in range(indent, last_indent, 4): + if current_tree_element is None: + raise ParseError(f"Error parsing line {lnr}: Invalid indentation") current_tree_element = current_tree_element.parent if re.search(r"\s*-?->\s*", line_content): @@ -103,16 +110,22 @@ def parse(self, file_path: str) -> Tree: if current_tree_element is None: # The current subtree is empty, set the subtree as its root element subtree, _, _ = self._lookup_subtree(call, lnr, subtrees) + if subtree.root_element is None: + raise ParseError(f"Error parsing line {lnr}: Empty subtree") current_subtree.set_root_element(subtree.root_element) else: # Copy this subtree and place it in the current tree position subtree_copy = self._instantiate_subtree(call, lnr, subtrees) + if not isinstance(current_tree_element, DecisionTreeElement): + raise ParseError(f"Error parsing line {lnr}: Branch parent must be a decision") current_tree_element.add_child_element(subtree_copy, result) elif re.search(r"\s*,\s*", call): # A sequence: a chain of actions, optionally ending in a decision or a subtree tokens = re.split(r"\s*,\s*", call) head, tail = self._create_chain(tokens, current_tree_element, lnr, subtrees) + if not isinstance(current_tree_element, DecisionTreeElement): + raise ParseError(f"Error parsing line {lnr}: Branch parent must be a decision") current_tree_element.add_child_element(head, result) if tokens[-1].startswith("$"): # A decision at the end of a sequence receives its children from the @@ -122,11 +135,15 @@ def parse(self, file_path: str) -> Tree: elif call.startswith("@"): # An action is called element = self._create_tree_element(call, current_tree_element, lnr) + if not isinstance(current_tree_element, DecisionTreeElement): + raise ParseError(f"Error parsing line {lnr}: Branch parent must be a decision") current_tree_element.add_child_element(element, result) elif call.startswith("$"): # A decision is called element = self._create_tree_element(call, current_tree_element, lnr) + if not isinstance(current_tree_element, DecisionTreeElement): + raise ParseError(f"Error parsing line {lnr}: Branch parent must be a decision") current_tree_element.add_child_element(element, result) current_tree_element = element @@ -152,19 +169,17 @@ def parse(self, file_path: str) -> Tree: last_indent = indent return tree - def _extract_parameters(self, token, lnr): + def _extract_parameters(self, token: str, lnr: int) -> tuple[str, Parameters, ParameterReferences]: """ Extract parameters from a token string in the form of name + key1:value1 + key2:value2 :param token: the string containing the name and the parameters - :type token: str :param lnr: Line number of the current line (used for error messages) - :type lnr: int :return: the name, a dict of set parameters, a dict of unset parameters """ parameters = re.split(r"\s*\+\s*", token) name = parameters.pop(0) - parameter_dict = dict() - unset_parameters = dict() + parameter_dict: Parameters = {} + unset_parameters: ParameterReferences = {} for parameter in parameters: try: parameter_key, parameter_value = parameter.split(":") @@ -177,28 +192,29 @@ def _extract_parameters(self, token, lnr): ) if parameter_value.startswith("%"): - parameter_value = self.node.get_parameter(parameter_value[1:]).value if self.node is not None else None - parameter_dict[parameter_key] = parameter_value + parameter_dict[parameter_key] = ( + self.node.get_parameter(parameter_value[1:]).value if self.node is not None else None + ) elif parameter_value.startswith("*"): # This is a reference to the value specified in the subtree unset_parameters[parameter_key] = parameter_value[1:] else: - parameter_value = yaml.safe_load(parameter_value) # universal interpretation of correct datatype - parameter_dict[parameter_key] = parameter_value + parameter_dict[parameter_key] = yaml.safe_load(parameter_value) return name, parameter_dict, unset_parameters - def _create_tree_element(self, token, parent, lnr): + def _create_tree_element( + self, token: str, parent: AbstractTreeElement[BlackboardT] | None, lnr: int + ) -> AbstractTreeElement[BlackboardT]: """ Create a tree element given a token and a parent. The method derives the type (Action/Decision) and optional parameters from the token :param token: the string describing the element in the dsd description :param parent: the parent element of the new element, None for root - :type parent: Union[DecisionTreeElement, ActionTreeElement] :param lnr: Line number of the current line (used for error messages) - :type lnr: int :return: a TreeElement containing the information given in token """ name, parameter_dict, unset_parameters = self._extract_parameters(token[1:], lnr) + element: AbstractTreeElement[BlackboardT] if token.startswith("$"): element = DecisionTreeElement(name, parent, parameter_dict, unset_parameters) elif token.startswith("@"): @@ -207,7 +223,13 @@ def _create_tree_element(self, token, parent, lnr): raise ParseError("An element has to start with either $ or @") return element - def _create_chain(self, tokens, parent, lnr, subtrees): + def _create_chain( + self, + tokens: list[str], + parent: AbstractTreeElement[BlackboardT] | None, + lnr: int, + subtrees: dict[str, Tree[BlackboardT]], + ) -> tuple[AbstractTreeElement[BlackboardT], AbstractTreeElement[BlackboardT]]: """ Create a sequence as a chain of tree elements linked via ``next_element``. @@ -216,16 +238,13 @@ def _create_chain(self, tokens, parent, lnr, subtrees): allows a decision or a subtree to follow a sequence of actions. :param tokens: The call tokens of the sequence (e.g. ``["@A", "@B", "$D"]``) - :type tokens: list[str] :param parent: The element the chain is attached to :param lnr: Line number of the current line (used for error messages) - :type lnr: int :param subtrees: The already parsed subtrees, needed to resolve a trailing subtree call - :type subtrees: dict :return: A tuple of the head element (the child of ``parent``) and the tail element of the chain """ - head = None - previous = None + head: AbstractTreeElement[BlackboardT] | None = None + previous: AbstractTreeElement[BlackboardT] | None = None for i, token in enumerate(tokens): is_last = i == len(tokens) - 1 if is_last and token.startswith("#"): @@ -246,11 +265,17 @@ def _create_chain(self, tokens, parent, lnr, subtrees): if previous is None: head = element else: + if not isinstance(previous, ActionTreeElement): + raise ParseError(f"Error parsing line {lnr}: Only actions may be followed") previous.next_element = element previous = element + if head is None or previous is None: + raise ParseError(f"Error parsing line {lnr}: Empty action chain") return head, previous - def _lookup_subtree(self, call, lnr, subtrees): + def _lookup_subtree( + self, call: str, lnr: int, subtrees: dict[str, Tree[BlackboardT]] + ) -> tuple[Tree[BlackboardT], Parameters, ParameterReferences]: """ Look up the subtree referenced by a ``#``-call and validate that its parameters match. @@ -270,7 +295,9 @@ def _lookup_subtree(self, call, lnr, subtrees): ) return subtree, parameters, unset_parameters - def _instantiate_subtree(self, call, lnr, subtrees): + def _instantiate_subtree( + self, call: str, lnr: int, subtrees: dict[str, Tree[BlackboardT]] + ) -> AbstractTreeElement[BlackboardT]: """ Create a deep copy of a referenced subtree with its parameters evaluated. @@ -280,11 +307,19 @@ def _instantiate_subtree(self, call, lnr, subtrees): :return: The root element of the instantiated subtree """ subtree, parameters, unset_parameters = self._lookup_subtree(call, lnr, subtrees) + if subtree.root_element is None: + raise ParseError(f"Error parsing line {lnr}: Empty subtree") subtree_copy = copy.deepcopy(subtree.root_element) self._evaluate_subtree_parameters(subtree_copy, parameters, unset_parameters, lnr) return subtree_copy - def _evaluate_subtree_parameters(self, root, parameters, unset_parameters, lnr): + def _evaluate_subtree_parameters( + self, + root: AbstractTreeElement[BlackboardT], + parameters: Parameters, + unset_parameters: ParameterReferences, + lnr: int, + ) -> None: """ Resolve the unset parameters of a copied subtree against the parameters given in the call. diff --git a/dynamic_stack_decider/dynamic_stack_decider/py.typed b/dynamic_stack_decider/dynamic_stack_decider/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_stack_decider/dynamic_stack_decider/tree.py b/dynamic_stack_decider/dynamic_stack_decider/tree.py index bc99048..9752b65 100644 --- a/dynamic_stack_decider/dynamic_stack_decider/tree.py +++ b/dynamic_stack_decider/dynamic_stack_decider/tree.py @@ -1,16 +1,23 @@ +from __future__ import annotations + import sys -from typing import Optional +from typing import TYPE_CHECKING, Generic + +from dynamic_stack_decider.types import BlackboardT, ParameterReferences, Parameters, TreeData + +if TYPE_CHECKING: + from dynamic_stack_decider.abstract_stack_element import AbstractStackElement -class Tree: +class Tree(Generic[BlackboardT]): """A tree defining a behaviour, parsed from a .dsd file""" - def __init__(self): + def __init__(self) -> None: # The root element of the tree - self.root_element: AbstractTreeElement | None = None - self.parameter_list = [] + self.root_element: AbstractTreeElement[BlackboardT] | None = None + self.parameter_list: list[str] = [] - def set_root_element(self, element: "AbstractTreeElement"): + def set_root_element(self, element: AbstractTreeElement[BlackboardT]) -> None: """ Set the root element of the tree @@ -18,17 +25,19 @@ def set_root_element(self, element: "AbstractTreeElement"): """ self.root_element = element - def __repr__(self): + def __repr__(self) -> str: return repr(self.root_element) - def repr_dict(self) -> dict: + def repr_dict(self) -> TreeData: """ Represent this tree as dictionary which is JSON encodable """ - return self.root_element.repr_dict() or {} + if self.root_element is None: + raise ValueError("Tree has no root element") + return self.root_element.repr_dict() -class AbstractTreeElement: +class AbstractTreeElement(Generic[BlackboardT]): """ An element (node) in the tree. Do not use directly, use one of DecisionTreeElement and ActionTreeElement instead @@ -37,18 +46,18 @@ class AbstractTreeElement: def __init__( self, name: str, - parent: Optional["AbstractTreeElement"], - parameters: dict | None = None, - unset_parameters: dict | None = None, - ): + parent: AbstractTreeElement[BlackboardT] | None, + parameters: Parameters | None = None, + unset_parameters: ParameterReferences | None = None, + ) -> None: self.name = name self.parent = parent self.parameters = parameters or dict() self.unset_parameters = unset_parameters or dict() - self.module = None - self.activation_reason = None + self.module: type[AbstractStackElement[BlackboardT]] | None = None + self.activation_reason: str | None = None - def get_child(self, activating_result: str) -> Optional["AbstractTreeElement"]: + def get_child(self, activating_result: str) -> AbstractTreeElement[BlackboardT] | None: """ Get the child that should be activated for the given result. This makes only sense for DecisionTreeElements; others return None @@ -58,11 +67,11 @@ def get_child(self, activating_result: str) -> Optional["AbstractTreeElement"]: """ return None - def set_activation_reason(self, reason): + def set_activation_reason(self, reason: str | None) -> None: """Set the result that activated this element""" self.activation_reason = reason - def repr_dict(self) -> dict: + def repr_dict(self) -> TreeData: """ Represent this tree element as dictionary which is JSON encodable """ @@ -76,13 +85,19 @@ def repr_dict(self) -> dict: } -class DecisionTreeElement(AbstractTreeElement): +class DecisionTreeElement(AbstractTreeElement[BlackboardT]): """ A tree element describing a decision. A decision has children that are executed on a certain result. Children can be added with add_child_element """ - def __init__(self, name, parent, parameters=None, unset_parameters=None): + def __init__( + self, + name: str, + parent: AbstractTreeElement[BlackboardT] | None, + parameters: Parameters | None = None, + unset_parameters: ParameterReferences | None = None, + ) -> None: """ Create a new DecisionTreeElement @@ -95,14 +110,14 @@ def __init__(self, name, parent, parameters=None, unset_parameters=None): super().__init__(name, parent, parameters, unset_parameters) # Dictionary that maps results of the decision to the corresponding child - self.children: dict[str, AbstractTreeElement] = dict() + self.children: dict[str, AbstractTreeElement[BlackboardT]] = dict() - def add_child_element(self, element: AbstractTreeElement, activating_result: str): + def add_child_element(self, element: AbstractTreeElement[BlackboardT], activating_result: str) -> None: """Add a child that will be executed when activating_result is returned""" self.children[activating_result] = element element.set_activation_reason(activating_result) - def get_child(self, activating_result: str) -> AbstractTreeElement | None: + def get_child(self, activating_result: str | None) -> AbstractTreeElement[BlackboardT]: """Get the child for a given result""" if not activating_result: # give advice about error @@ -115,16 +130,16 @@ def get_child(self, activating_result: str) -> AbstractTreeElement | None: else: raise KeyError(f"{activating_result} does not lead to a child of {str(self)} and no ELSE was specified") - def __repr__(self): + def __repr__(self) -> str: r = "$" + self.name + f" ({self.parameters}): " for result, child in self.children.items(): r += result + ": {" + repr(child) + "} " return r - def __str__(self): + def __str__(self) -> str: return self.name - def repr_dict(self) -> dict: + def repr_dict(self) -> TreeData: """ Represent this decision as dictionary which is JSON encodable """ @@ -136,7 +151,7 @@ def repr_dict(self) -> dict: return result -class ActionTreeElement(AbstractTreeElement): +class ActionTreeElement(AbstractTreeElement[BlackboardT]): """ A tree element describing an action. An action has optional parameters that will be passed to the module on creation. @@ -148,7 +163,13 @@ class ActionTreeElement(AbstractTreeElement): action. """ - def __init__(self, name, parent, parameters=None, unset_parameters=None): + def __init__( + self, + name: str, + parent: AbstractTreeElement[BlackboardT] | None, + parameters: Parameters | None = None, + unset_parameters: ParameterReferences | None = None, + ) -> None: """ Create a new ActionTreeElement :param name: the class name of the corresponding AbstractActionElement @@ -159,9 +180,9 @@ def __init__(self, name, parent, parameters=None, unset_parameters=None): super().__init__(name, parent, parameters, unset_parameters) # The element that is activated when this action pops itself. None if this # action is the last (or only) element in its chain. - self.next_element: AbstractTreeElement | None = None + self.next_element: AbstractTreeElement[BlackboardT] | None = None - def set_activation_reason(self, reason): + def set_activation_reason(self, reason: str | None) -> None: """Set the activation reason for this action and propagate it along the chain""" self.activation_reason = reason # All elements of a chain share the activation reason of the head so that a @@ -170,15 +191,15 @@ def set_activation_reason(self, reason): if self.next_element is not None: self.next_element.set_activation_reason(reason) - def __repr__(self): + def __repr__(self) -> str: if self.next_element is not None: return f"@{self.name} ({self.parameters}) -> {repr(self.next_element)}" return f"@{self.name} ({self.parameters})" - def __str__(self): + def __str__(self) -> str: return self.name - def repr_dict(self) -> dict: + def repr_dict(self) -> TreeData: """ Represent this action as dictionary which is JSON encodable """ diff --git a/dynamic_stack_decider/dynamic_stack_decider/types.py b/dynamic_stack_decider/dynamic_stack_decider/types.py new file mode 100644 index 0000000..fe062a9 --- /dev/null +++ b/dynamic_stack_decider/dynamic_stack_decider/types.py @@ -0,0 +1,33 @@ +"""Shared parameter, blackboard, and debug representation types.""" + +from __future__ import annotations + +from typing import Literal, TypedDict, TypeVar + +BlackboardT = TypeVar("BlackboardT") +Parameters = dict[str, object] +ParameterReferences = dict[str, str] + + +class ElementData(TypedDict): + type: Literal["abstract", "action", "decision"] + name: str + debug_data: dict[str, object] + + +class StackData(ElementData): + id: int + activation_reason: str | None + next: StackData | None + + +class _TreeFields(TypedDict): + id: int + name: str + parameters: Parameters + + +class TreeData(_TreeFields, total=False): + type: Literal["action", "decision"] + children: dict[str, TreeData] + next: TreeData | None diff --git a/dynamic_stack_decider/setup.py b/dynamic_stack_decider/setup.py index ea7cbe6..8c82793 100644 --- a/dynamic_stack_decider/setup.py +++ b/dynamic_stack_decider/setup.py @@ -8,6 +8,7 @@ description="Persistent actions selected by reevaluable decision trees", python_requires=">=3.10", packages=find_packages(exclude=["test", "test.*", "tests", "tests.*"]), + package_data={package_name: ["py.typed"]}, data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), diff --git a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_follower.py b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_follower.py index 9e6fb40..7d4d03b 100644 --- a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_follower.py +++ b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_follower.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import json import uuid +from typing import cast import pydot +from dynamic_stack_decider.types import Parameters, StackData, TreeData from PyQt5.QtGui import QStandardItem, QStandardItemModel from rclpy.node import Node from rclpy.qos import DurabilityPolicy, QoSProfile @@ -13,7 +17,7 @@ class ParseError(Exception): class DsdFollower: - def __init__(self, node: Node, debug_topic: str): + def __init__(self, node: Node, debug_topic: str) -> None: """ Creates a new DSD follower which subscribes to the given debug topics, fetches it's tree and listens for the current stack. @@ -26,8 +30,8 @@ def __init__(self, node: Node, debug_topic: str): self.dsd_debug_topic = debug_topic - self.tree: dict | None = None - self.stack: dict | None = None + self.tree: TreeData | None = None + self.stack: StackData | None = None self._cached_dotgraph: pydot.Dot | None = None @@ -46,19 +50,19 @@ def __init__(self, node: Node, debug_topic: str): self.stack_sub = self._node.create_subscription(String, dsd_stack_topic, self._stack_callback, 10) self._node.get_logger().info(f"Subscribed to {dsd_stack_topic}") - def _tree_callback(self, msg): + def _tree_callback(self, msg: String) -> None: self._node.get_logger().info("Received tree") # Deserialize the tree message # This is a json string because the tree is a dynamically nested structure of variable length - self.tree = json.loads(msg.data) + self.tree = cast(TreeData, json.loads(msg.data)) - def _stack_callback(self, msg): + def _stack_callback(self, msg: String) -> None: # Abort if no tree was received yet if self.tree is None: return # Deserialize the stack message - stack = json.loads(msg.data) + stack = cast(StackData | None, json.loads(msg.data)) # Check if the stack changed (ignore debug_data log messages) if DsdFollower.stack_dict_changed(self.stack, stack, ignore_keys=["debug_data"]): @@ -68,18 +72,18 @@ def _stack_callback(self, msg): # Update stack self.stack = stack - def reset_cache(self): + def reset_cache(self) -> None: """Reset the cached dotgraph""" self._cached_dotgraph = None @staticmethod - def stack_dict_changed(old_stack_element, new_stack_element, ignore_keys) -> bool: + def stack_dict_changed(old_stack_element: object, new_stack_element: object, ignore_keys: list[str]) -> bool: """ Check if two stack dicts are different """ if type(old_stack_element) is not type(new_stack_element): return True - elif isinstance(old_stack_element, dict): + elif isinstance(old_stack_element, dict) and isinstance(new_stack_element, dict): # Check if the length of the stack changed if len(old_stack_element) != len(new_stack_element): return True @@ -92,7 +96,7 @@ def stack_dict_changed(old_stack_element, new_stack_element, ignore_keys) -> boo return True if DsdFollower.stack_dict_changed(old_stack_element[key], new_stack_element[key], ignore_keys): return True - elif isinstance(old_stack_element, list): + elif isinstance(old_stack_element, list) and isinstance(new_stack_element, list): # Check if the length of the stack changed if len(old_stack_element) != len(new_stack_element): return True @@ -109,7 +113,7 @@ def stack_dict_changed(old_stack_element, new_stack_element, ignore_keys) -> boo return False @staticmethod - def _error_dotgraph(): + def _error_dotgraph() -> pydot.Dot: dot = pydot.Dot(graph_type="digraph") uid1 = str(uuid.uuid4()) @@ -131,18 +135,18 @@ def _error_dotgraph(): return dot @staticmethod - def _empty_item_model(): + def _empty_item_model() -> QStandardItemModel: return QStandardItemModel() @staticmethod - def _dot_node_from_tree_element(tree_element: dict, stack_element: dict | None = None) -> pydot.Node: + def _dot_node_from_tree_element(tree_element: TreeData, stack_element: StackData | None = None) -> pydot.Node: """ :param tree_element: The node from the dsd tree which should be converted to a dot node :param stack_element: The corresponding element from the stack :return: The corresponding dot node """ - def param_string(params: dict) -> str: + def param_string(params: Parameters) -> str: """ :param params: A dict of parameters :return: A string representation of the parameters @@ -184,10 +188,15 @@ def param_string(params: dict) -> str: dot_node_params["color"] = "gray" if stack_element is None else "orangered" # Create node in graph - return pydot.Node(**dot_node_params) + return pydot.Node( + name=dot_node_params["name"], + shape=dot_node_params["shape"], + label=dot_node_params["label"], + color=dot_node_params["color"], + ) @staticmethod - def _stack_matches_tree(stack_element: dict | None, tree_element: dict) -> bool: + def _stack_matches_tree(stack_element: StackData | None, tree_element: TreeData) -> bool: """ Check whether a stack element corresponds to a given tree element. @@ -198,8 +207,8 @@ def _stack_matches_tree(stack_element: dict | None, tree_element: dict) -> bool: return stack_element is not None and stack_element["id"] == tree_element["id"] def _stack_to_dotgraph( - self, dot: pydot.Dot, subtree_root: dict, stack_root: dict | None = None, full_tree: bool = False - ) -> (pydot.Dot, str): + self, dot: pydot.Dot, subtree_root: TreeData, stack_root: StackData | None = None, full_tree: bool = False + ) -> tuple[pydot.Dot, str]: """ Recursively modify dot to include every element of the stack @@ -241,17 +250,16 @@ def _stack_to_dotgraph( # Append the successor of a chain (e.g. the next action of a sequence) if it exists. # We render the whole chain if any element of it is on the stack or if the whole tree is requested. - if subtree_root.get("next") is not None and (stack_root is not None or full_tree): + successor = subtree_root.get("next") + if successor is not None and (stack_root is not None or full_tree): # We pass the same active stack element down the whole chain. Only the element it actually # corresponds to (matched via its id) is rendered as active; all others are rendered inactive. - dot, next_uid = self._stack_to_dotgraph(dot, subtree_root["next"], stack_root, full_tree) + dot, next_uid = self._stack_to_dotgraph(dot, successor, stack_root, full_tree) # Connect the successor to this element dot.add_edge(pydot.Edge(src=dot_node.get_name(), dst=next_uid)) return dot, dot_node.get_name() - def _append_debug_data_to_item( - self, parent_item: QStandardItem, debug_data: dict | list | int | float | str | bool - ): + def _append_debug_data_to_item(self, parent_item: QStandardItem, debug_data: object) -> None: """ Appends debug_data of a given element and its children to a QStandardItem. """ @@ -298,7 +306,7 @@ def to_dotgraph(self, full_tree: bool) -> pydot.Dot: return self._cached_dotgraph - def to_q_item_model(self): + def to_q_item_model(self) -> QStandardItemModel: """ Represent the DSDs debug data as QITemModel """ @@ -310,7 +318,7 @@ def to_q_item_model(self): model = QStandardItemModel() # Start with the root/bottom of the stack - stack_element = self.stack + stack_element: StackData | None = self.stack # Go through all stack elements while stack_element is not None: @@ -348,7 +356,7 @@ def to_q_item_model(self): return model - def destroy(self): + def destroy(self) -> None: """ Cleanup the subscriptions, so we don't receive any more data that we don't need. This improves the performance. diff --git a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_visualization_plugin.py b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_visualization_plugin.py index ff67e5c..79acda8 100644 --- a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_visualization_plugin.py +++ b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/dsd_visualization_plugin.py @@ -28,16 +28,19 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + import os import sys import uuid +from typing import Protocol import pydot from ament_index_python import get_package_share_directory -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimerEvent from PyQt5.QtGui import QIcon, QPainter, QStandardItemModel from PyQt5.QtSvg import QSvgGenerator -from PyQt5.QtWidgets import QFileDialog, QGraphicsScene, QWidget +from PyQt5.QtWidgets import QCheckBox, QComboBox, QFileDialog, QGraphicsScene, QPushButton, QTreeView, QWidget from PyQt5.uic import loadUi from qt_dotgraph.dot_to_qt import DotToQtGenerator from qt_dotgraph.pydotfactory import PydotFactory @@ -49,8 +52,35 @@ from .interactive_graphics_view import InteractiveGraphicsView +class PluginContext(Protocol): + node: Node + + def add_widget(self, widget: QWidget) -> None: ... + + +class Settings(Protocol): + def set_value(self, key: str, value: object) -> None: ... + + def value(self, key: str, default: object) -> object: ... + + +class DsdWidget(QWidget): + """Widgets populated by the packaged Qt UI definition.""" + + graphics_view: InteractiveGraphicsView + stack_prop_tree_view: QTreeView + dsd_selector_combo_box: QComboBox + highlight_connections_check_box: QCheckBox + show_full_tree: QCheckBox + auto_fit_graph_check_box: QCheckBox + fit_in_view_push_button: QPushButton + freeze_push_button: QPushButton + refresh_combobox_push_button: QPushButton + save_as_svg_push_button: QPushButton + + class DsdVizPlugin(Plugin): - def __init__(self, context): + def __init__(self, context: PluginContext) -> None: super().__init__(context) self._node: Node = context.node self._initialized = False # This gets set to true once the plugin hast completely finished loading @@ -62,13 +92,13 @@ def __init__(self, context): self._init_plugin(context) # Performance optimization variables - self._prev_dotgraph = None - self._prev_QItemModel = None + self._prev_dotgraph: pydot.Dot | None = None + self._prev_QItemModel: QStandardItemModel | None = None - def _init_plugin(self, context): + def _init_plugin(self, context: PluginContext) -> None: self.setObjectName("DSD-Visualization") - self._widget = QWidget() + self._widget = DsdWidget() self._widget.setObjectName(self.objectName()) # load qt ui definition from file @@ -90,7 +120,7 @@ def _init_plugin(self, context): self._widget.auto_fit_graph_check_box.toggled.connect(self.fit_in_view) # Freezing - def toggle_freeze(): + def toggle_freeze() -> None: self.freeze = not self.freeze self.refresh() @@ -110,7 +140,7 @@ def toggle_freeze(): self._widget.refresh_combobox_push_button.pressed.connect(self.discover_dsd_instances) # Hook that resets the dot cache and thus forces a redraw of the dotgraph - def reset_dot_cache(): + def reset_dot_cache() -> None: if self.dsd_follower is not None: self.dsd_follower.reset_cache() @@ -121,13 +151,7 @@ def reset_dot_cache(): self._widget.fit_in_view_push_button.pressed.connect(reset_dot_cache) self._widget.freeze_push_button.toggled.connect(reset_dot_cache) - # Add to InteractiveGraphicsView mouse wheel event that disables auto-fit-in-view - def wheel_event(event): - self._widget.auto_fit_graph_check_box.setChecked(False) - self._widget.graphics_view.__class__.wheelEvent(self._widget.graphics_view, event) - - # Overwrite the original wheelEvent handler - self._widget.graphics_view.wheelEvent = wheel_event + self._widget.graphics_view.zoomed.connect(lambda: self._widget.auto_fit_graph_check_box.setChecked(False)) # Add widget to the user interface context.add_widget(self._widget) @@ -135,7 +159,7 @@ def wheel_event(event): # Start a timer that calls back every 100 ms self._timer_id = self.startTimer(100) - def discover_dsd_instances(self): + def discover_dsd_instances(self) -> None: """Updates the dict of known dsd instances""" # Store all DSDs in a dict dsd_instances = {} @@ -158,7 +182,7 @@ def discover_dsd_instances(self): for name in self.running_dsd_instances.keys(): self._widget.dsd_selector_combo_box.addItem(name) - def save_settings(self, plugin_settings, instance_settings): + def save_settings(self, plugin_settings: object, instance_settings: Settings) -> None: super().save_settings(plugin_settings, instance_settings) instance_settings.set_value("auto_fit_graph_check_box_state", self._widget.auto_fit_graph_check_box.isChecked()) @@ -167,7 +191,7 @@ def save_settings(self, plugin_settings, instance_settings): ) instance_settings.set_value("show_full_tree_check_box_state", self._widget.show_full_tree.isChecked()) - def restore_settings(self, plugin_settings, instance_settings): + def restore_settings(self, plugin_settings: object, instance_settings: Settings) -> None: super().restore_settings(plugin_settings, instance_settings) self._widget.auto_fit_graph_check_box.setChecked( @@ -183,7 +207,7 @@ def restore_settings(self, plugin_settings, instance_settings): self._initialized = True self.refresh() - def save_svg_to_file(self): + def save_svg_to_file(self) -> None: file_name, _ = QFileDialog.getSaveFileName( self._widget, self.tr("Save as SVG"), "stackmachine.svg", self.tr("Scalable Vector Graphic (*.svg)") ) @@ -199,7 +223,7 @@ def save_svg_to_file(self): painter.end() # fmt: off - def timerEvent(self, timer_event): #noqa: N802 + def timerEvent(self, timer_event: QTimerEvent | None) -> None: #noqa: N802 # fmt: on """This gets called by QT whenever the timer ticks""" # Refresh the viz if the freeze button is not pressed @@ -210,11 +234,11 @@ def timerEvent(self, timer_event): #noqa: N802 if self._widget.auto_fit_graph_check_box.isChecked(): self.fit_in_view() - def fit_in_view(self): + def fit_in_view(self) -> None: """Rescales the tree to fit the window""" self._widget.graphics_view.fitInView(self._scene.itemsBoundingRect(), Qt.KeepAspectRatio) - def refresh(self): + def refresh(self) -> None: """Refresh the complete drawn representation""" # Show message if the plugin is not yet completely initialized @@ -232,7 +256,7 @@ def refresh(self): )) self._render_debug_data(self.dsd_follower.to_q_item_model()) - def _render_messages(self, *messages): + def _render_messages(self, *messages: str) -> None: """Render simple messages on the canvas""" msg_dot = pydot.Dot() @@ -243,7 +267,7 @@ def _render_messages(self, *messages): self._render_dotgraph(msg_dot) self._render_debug_data(QStandardItemModel(self._scene)) - def _render_dotgraph(self, dotgraph): + def _render_dotgraph(self, dotgraph: pydot.Dot) -> None: """Render the specified dotgraph on canvas""" # Only redraw when the graph differs from the previous one @@ -264,15 +288,15 @@ def _render_dotgraph(self, dotgraph): nodes, edges = DotToQtGenerator().dotcode_to_qt_items(dotcode, highlight_level, same_label_siblings=False) # Add generated items to scene - for node_item in nodes: - self._scene.addItem(nodes.get(node_item)) - for edge_items in edges: - for edge_item in edges.get(edge_items): + for node_item in nodes.values(): + self._scene.addItem(node_item) + for edge_items in edges.values(): + for edge_item in edge_items: edge_item.add_to_scene(self._scene) self._scene.setSceneRect(self._scene.itemsBoundingRect()) - def _render_debug_data(self, qitem_model): + def _render_debug_data(self, qitem_model: QStandardItemModel) -> None: """Render debug data in the tree view on the right side of the scene""" # Only redraw when the item-model differs from the previous one @@ -284,7 +308,7 @@ def _render_debug_data(self, qitem_model): self._widget.stack_prop_tree_view.setModel(qitem_model) self._widget.stack_prop_tree_view.expandAll() - def set_dsd(self, name): + def set_dsd(self, name: str) -> None: """ Set the target dsd @@ -306,7 +330,7 @@ def set_dsd(self, name): self.dsd_follower = DsdFollower(self._node, self.running_dsd_instances[name]) -def main(): +def main() -> None: plugin = "dynamic_stack_decider_visualization.dsd_visualization_plugin.DsdVizPlugin" main = Main(filename=plugin) sys.exit(main.main(standalone=plugin)) diff --git a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/interactive_graphics_view.py b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/interactive_graphics_view.py index 23fc567..e4a035b 100644 --- a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/interactive_graphics_view.py +++ b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/interactive_graphics_view.py @@ -29,31 +29,41 @@ # POSSIBILITY OF SUCH DAMAGE. -from PyQt5.QtCore import QPointF, QRectF, Qt -from PyQt5.QtGui import QTransform -from PyQt5.QtWidgets import QGraphicsView +from __future__ import annotations + +from PyQt5.QtCore import QPoint, QPointF, QRectF, Qt, pyqtSignal +from PyQt5.QtGui import QMouseEvent, QWheelEvent +from PyQt5.QtWidgets import QGraphicsView, QWidget # ruff: noqa: N802 class InteractiveGraphicsView(QGraphicsView): - def __init__(self, parent=None): + zoomed = pyqtSignal() + + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("InteractiveGraphicsView") - self._last_pan_point = None - self._last_scene_center = None + self._last_pan_point: QPoint | None = None + self._last_scene_center: QPointF | None = None - def mousePressEvent(self, mouse_event): + def mousePressEvent(self, mouse_event: QMouseEvent | None) -> None: + if mouse_event is None: + return self._last_pan_point = mouse_event.pos() self._last_scene_center = self._map_to_scene_f(QRectF(self.frameRect()).center()) self.setCursor(Qt.ClosedHandCursor) - def mouseReleaseEvent(self, mouse_event): + def mouseReleaseEvent(self, mouse_event: QMouseEvent | None) -> None: + if mouse_event is None: + return self.setCursor(Qt.OpenHandCursor) self._last_pan_point = None - def mouseMoveEvent(self, mouse_event): - if self._last_pan_point is not None: + def mouseMoveEvent(self, mouse_event: QMouseEvent | None) -> None: + if mouse_event is None: + return + if self._last_pan_point is not None and self._last_scene_center is not None: delta_scene = self.mapToScene(mouse_event.pos()) - self.mapToScene(self._last_pan_point) if not delta_scene.isNull(): self.centerOn(self._last_scene_center - delta_scene) @@ -61,18 +71,17 @@ def mouseMoveEvent(self, mouse_event): self._last_pan_point = mouse_event.pos() QGraphicsView.mouseMoveEvent(self, mouse_event) - def wheelEvent(self, wheel_event): - if wheel_event.modifiers() == Qt.NoModifier: - try: - delta = wheel_event.angleDelta().y() - except AttributeError: - delta = wheel_event.delta() + def wheelEvent(self, wheel_event: QWheelEvent | None) -> None: + if wheel_event is None: + return + self.zoomed.emit() + if not wheel_event.modifiers(): + delta = wheel_event.angleDelta().y() delta = max(min(delta, 480), -480) mouse_before_scale_in_scene = self.mapToScene(wheel_event.pos()) scale_factor = 1 + (0.2 * (delta / 120.0)) - scaling = QTransform(scale_factor, 0, 0, scale_factor, 0, 0) - self.setTransform(self.transform() * scaling) + self.scale(scale_factor, scale_factor) mouse_after_scale_in_scene = self.mapToScene(wheel_event.pos()) center_in_scene = self.mapToScene(self.frameRect().center()) @@ -82,7 +91,7 @@ def wheelEvent(self, wheel_event): else: QGraphicsView.wheelEvent(self, wheel_event) - def _map_to_scene_f(self, point_f): + def _map_to_scene_f(self, point_f: QPointF) -> QPointF: point = point_f.toPoint() if point_f.x() == point.x() and point_f.y() == point.y(): # map integer coordinates diff --git a/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/py.typed b/dynamic_stack_decider_visualization/dynamic_stack_decider_visualization/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_stack_decider_visualization/setup.py b/dynamic_stack_decider_visualization/setup.py index 851bd46..aa8ea8b 100644 --- a/dynamic_stack_decider_visualization/setup.py +++ b/dynamic_stack_decider_visualization/setup.py @@ -8,6 +8,7 @@ description="ROS 2 RQT visualization for the Dynamic Stack Decider", python_requires=">=3.10", packages=find_packages(exclude=["test", "test.*", "tests", "tests.*"]), + package_data={package_name: ["py.typed"]}, data_files=[ ("share/" + package_name + "/resource", ["resource/StackmachineViz.ui"]), ("share/ament_index/resource_index/packages", ["resource/" + package_name]), diff --git a/examples/demo.dsd b/examples/demo.dsd new file mode 100644 index 0000000..0b47981 --- /dev/null +++ b/examples/demo.dsd @@ -0,0 +1,4 @@ +--> +$BallSeen + YES --> @TrackBall + NO --> @SearchBall diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..27a7295 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,44 @@ +"""A typed behavior that needs no robot, ROS node, or hardware.""" + +from dataclasses import dataclass +from pathlib import Path + +from dynamic_stack_decider import DSD, AbstractActionElement, AbstractDecisionElement + + +@dataclass +class Blackboard: + ball_seen: bool = False + + +class BallSeen(AbstractDecisionElement[Blackboard]): + def perform(self, reevaluate: bool = False) -> str: + return "YES" if self.blackboard.ball_seen else "NO" + + def get_reevaluate(self) -> bool: + return True + + +class TrackBall(AbstractActionElement[Blackboard]): + def perform(self, reevaluate: bool = False) -> None: + print("Tracking the ball") + + +class SearchBall(AbstractActionElement[Blackboard]): + def perform(self, reevaluate: bool = False) -> None: + print("Searching for the ball") + + +def main() -> None: + blackboard = Blackboard() + dsd = DSD(blackboard) + dsd.actions = {"TrackBall": TrackBall, "SearchBall": SearchBall} + dsd.decisions = {"BallSeen": BallSeen} + dsd.load_behavior(Path(__file__).with_suffix(".dsd")) + dsd.update() + blackboard.ball_seen = True + dsd.update() + + +if __name__ == "__main__": + main() diff --git a/pixi.lock b/pixi.lock index e195b6c..34d4c9b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -14,6 +14,7 @@ environments: packages: linux-64: - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://prefix.dev/conda-forge/linux-64/ast-serialize-0.12.1-py311hf77984d_0.conda - conda: https://prefix.dev/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://prefix.dev/conda-forge/linux-64/c-ares-1.34.8-hebe6cf0_2.conda - conda: https://prefix.dev/conda-forge/linux-64/cmake-4.4.4-hff7ac6b_0.conda @@ -58,12 +59,15 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://prefix.dev/conda-forge/linux-64/lttng-ust-2.13.9-hf5eda4c_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/mypy-2.4.0-py312h1b36aeb_0.conda - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://prefix.dev/conda-forge/linux-64/numpy-2.5.3-py312he827f4e_0.conda - conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda - conda: https://prefix.dev/conda-forge/linux-64/pcre-8.45-h9c3ff4c_0.tar.bz2 - conda: https://prefix.dev/conda-forge/linux-64/pkg-config-0.29.2-h1114479_1013.conda + - conda: https://prefix.dev/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda - conda: https://prefix.dev/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda + - conda: https://prefix.dev/conda-forge/linux-64/python-librt-0.16.0-py312h1b36aeb_0.conda - conda: https://prefix.dev/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - conda: https://prefix.dev/conda-forge/linux-64/rhash-1.4.6-hb03c661_2.conda @@ -74,10 +78,12 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda - conda: https://prefix.dev/conda-forge/linux-64/yaml-cpp-0.8.0-h54a6638_1.conda - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/argcomplete-3.7.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.12.14-py312hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda @@ -92,7 +98,9 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/lark-parser-0.12.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/mccabe-0.7.0-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pycodestyle-2.15.0-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/pydocstyle-6.3.0-pyhd8ed1ab_1.conda @@ -104,11 +112,13 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/python-build-1.6.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.12.14-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-9_cp312.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.10.2-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://prefix.dev/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/types-pyyaml-6.0.12.20260906-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://prefix.dev/conda-forge/noarch/wheel-0.48.0-pyhd8ed1ab_0.conda @@ -276,6 +286,7 @@ environments: linux-64: - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://prefix.dev/conda-forge/linux-64/alsa-lib-1.2.16.1-h7cc23a3_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/ast-serialize-0.12.1-py311hf77984d_0.conda - conda: https://prefix.dev/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2 - conda: https://prefix.dev/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2 - conda: https://prefix.dev/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda @@ -387,6 +398,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://prefix.dev/conda-forge/linux-64/lttng-ust-2.13.9-hf5eda4c_0.conda - conda: https://prefix.dev/conda-forge/linux-64/mpg123-1.33.7-h877a99e_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/mypy-2.4.0-py312h1b36aeb_0.conda - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://prefix.dev/conda-forge/linux-64/nspr-4.40-h29cc59b_0.conda - conda: https://prefix.dev/conda-forge/linux-64/nss-3.118-h445c969_0.conda @@ -398,11 +410,13 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/pcre2-10.47-h8b3dc9c_1.conda - conda: https://prefix.dev/conda-forge/linux-64/pixman-0.46.4-h54a6638_3.conda - conda: https://prefix.dev/conda-forge/linux-64/pkg-config-0.29.2-h1114479_1013.conda + - conda: https://prefix.dev/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda - conda: https://prefix.dev/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda - conda: https://prefix.dev/conda-forge/linux-64/pulseaudio-client-17.0-hb10c1b2_4.conda - conda: https://prefix.dev/conda-forge/linux-64/pyqt-5.15.11-py312h82c0db2_2.conda - conda: https://prefix.dev/conda-forge/linux-64/pyqt5-sip-12.17.0-py312h1289d80_2.conda - conda: https://prefix.dev/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda + - conda: https://prefix.dev/conda-forge/linux-64/python-librt-0.16.0-py312h1b36aeb_0.conda - conda: https://prefix.dev/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda - conda: https://prefix.dev/conda-forge/linux-64/qt-main-5.15.15-h0c412b5_8.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda @@ -440,11 +454,13 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda - conda: https://prefix.dev/conda-forge/linux-64/yaml-cpp-0.8.0-h54a6638_1.conda - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/adwaita-icon-theme-51.0-unix_0.conda - conda: https://prefix.dev/conda-forge/noarch/argcomplete-3.7.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.12.14-py312hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-4.2.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda @@ -465,7 +481,9 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/lark-parser-0.12.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/mccabe-0.7.0-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/pycodestyle-2.15.0-pyh5ded981_0.conda @@ -476,16 +494,19 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pyparsing-3.3.3-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/pyproject_hooks-1.2.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/pyqt-builder-1.18.2-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/pyqt-stubs-5.15.6.0-pyhb6d5dde_1.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/python-build-1.6.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.12.14-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-9_cp312.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.10.2-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://prefix.dev/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/types-pyyaml-6.0.12.20260906-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://prefix.dev/conda-forge/noarch/wheel-0.48.0-pyhd8ed1ab_0.conda @@ -634,6 +655,23 @@ packages: - alsa-lib >=1.2.16.1,<1.3.0a0 size: 594986 timestamp: 1787763412911 +- conda: https://prefix.dev/conda-forge/linux-64/ast-serialize-0.12.1-py311hf77984d_0.conda + noarch: python + sha256: 4c9212bd3446e8b6bb2ce22cc2a47ce8f5b3c6414141325db995b88265ee0e12 + md5: 378c56d6a4ecd84c0ee6742f78a75737 + depends: + - python >=3.11 + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 1153944 + timestamp: 1791121762897 - conda: https://prefix.dev/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2 sha256: 26ab9386e80bf196e51ebe005da77d57decf6d989b4f34d96130560bc133479c md5: 6b889f174df1e0f816276ae69281af4d @@ -2381,6 +2419,25 @@ packages: - mpg123 >=1.33.7,<1.34.0a0 size: 491351 timestamp: 1787311495359 +- conda: https://prefix.dev/conda-forge/linux-64/mypy-2.4.0-py312h1b36aeb_0.conda + sha256: 7b83aa836c42a6da61d5a9431ea5e21c13c67a491d573fd1952986e21d4c7852 + md5: cd9eb4cc02a3fbca824bcf6592de444c + depends: + - ast-serialize >=0.11.2,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.16.0 + - typing_extensions >=4.14.0 + - psutil >=4.0 + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + run_exports: {} + size: 23685894 + timestamp: 1790965612973 - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 md5: ee6c0cd80a60961a1f48aa3e0b91f986 @@ -2552,6 +2609,19 @@ packages: run_exports: {} size: 141376 timestamp: 1788677377648 +- conda: https://prefix.dev/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda + sha256: 9b6f0247591f062a188cd42580916476c0d939a89d65f3ecefb37e84b5c17d7a + md5: 2f8faed6c111c9ed0c1d217bc1be75f2 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 225549 + timestamp: 1789682039049 - conda: https://prefix.dev/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda sha256: 4a44fd00ea73b79ca2c89b0727b9ccf61c506ead71e67a9abfa4c590042b5a4a md5: bb66b610707b811e3c65181a6431e7a8 @@ -2668,6 +2738,19 @@ packages: - python size: 23044161 timestamp: 1788392496306 +- conda: https://prefix.dev/conda-forge/linux-64/python-librt-0.16.0-py312h1b36aeb_0.conda + sha256: 12671324ba50c377e2631c91f060ca42c36f548993178520a477d348cc3aa80d + md5: 43a57694af193ac4e161342a63d817e7 + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + run_exports: {} + size: 169332 + timestamp: 1790684476183 - conda: https://prefix.dev/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda sha256: cb142bfd92f6e55749365ddc244294fa7b64db6d08c45b018ff1c658907bfcbf md5: 15878599a87992e44c059731771591cb @@ -3272,6 +3355,17 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 601301 timestamp: 1786599621503 +- conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda + sha256: 2a7204314663eeda5dec482a956f0e2eaf289bd5b9953eaaaad0e81aa64638f2 + md5: 3845f3d75991bae0fb90884662f4327c + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + run_exports: {} + size: 8144 + timestamp: 1784221492234 - conda: https://prefix.dev/conda-forge/noarch/adwaita-icon-theme-51.0-unix_0.conda sha256: def01b148aa54d4de29b7d4bf58d941c8699582201db14cf78c6c89fa1666434 md5: 6f62266fb2bfc35a5071cfa1d6ffdcc9 @@ -3338,6 +3432,17 @@ packages: run_exports: {} size: 27011 timestamp: 1733218222191 +- conda: https://prefix.dev/conda-forge/noarch/cpython-3.12.14-py312hd8ed1ab_3.conda + noarch: generic + sha256: 1052869d599008850098c033f9568a2e28bb31238705969719be69a87474cc93 + md5: adee9213d9a98d7f8b5627209c8b4ae0 + depends: + - python >=3.12,<3.13.0a0 + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 47172 + timestamp: 1788391292962 - conda: https://prefix.dev/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e md5: 58638f77697c4f6726753eb8be34818b @@ -3580,6 +3685,16 @@ packages: run_exports: {} size: 12934 timestamp: 1733216573915 +- conda: https://prefix.dev/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 + md5: e9c622e0d00fa24a6292279af3ab6d06 + depends: + - python >=3.9 + license: MIT + license_family: MIT + run_exports: {} + size: 11766 + timestamp: 1745776666688 - conda: https://prefix.dev/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee md5: eb52d14a901e23c39e9e7b4a1a5c015f @@ -3602,6 +3717,16 @@ packages: run_exports: {} size: 116363 timestamp: 1785888127370 +- conda: https://prefix.dev/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 + md5: 11adc78451c998c0fd162584abfa3559 + depends: + - python >=3.10 + license: MPL-2.0 + license_family: MOZILLA + run_exports: {} + size: 56559 + timestamp: 1777271601895 - conda: https://prefix.dev/conda-forge/noarch/platformdirs-4.12.3-pyh5ded981_0.conda sha256: b93a46b1c1fb34a380b0afa309040d603ebefdb8184a87a4bfaeef989a07cbc4 md5: 96bd08b41fbb94da012c8f5c56132519 @@ -3751,6 +3876,16 @@ packages: run_exports: {} size: 2952282 timestamp: 1766858321453 +- conda: https://prefix.dev/conda-forge/noarch/pyqt-stubs-5.15.6.0-pyhb6d5dde_1.conda + sha256: b66c53cd1bc940987d63598759c7bb86af05554bdf9a6eb5af911f1d7826e5c0 + md5: 8daf174cf7e54fcebfa3da31fd37edb2 + depends: + - pyqt ~=5.15.0 + - python >=3.9 + license: GPL-3.0-or-later + license_family: GPL + size: 304801 + timestamp: 1735898434166 - conda: https://prefix.dev/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda sha256: 39f41a52eb6f927caf5cd42a2ff98a09bb850ce9758b432869374b6253826962 md5: da0c42269086f5170e2b296878ec13a6 @@ -3827,6 +3962,16 @@ packages: run_exports: {} size: 39252 timestamp: 1789811798811 +- conda: https://prefix.dev/conda-forge/noarch/python-gil-3.12.14-hd8ed1ab_3.conda + sha256: 7096d98d7dd0cdb9256c5714d7228a8ce7aca3bd014304ea5c1673c259df72ef + md5: 7fafcd204183cfa7c1ae4ad9c2d8d414 + depends: + - cpython 3.12.14.* + - python_abi * *_cp312 + license: Python-2.0 + run_exports: {} + size: 47142 + timestamp: 1788391303150 - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-9_cp312.conda build_number: 9 sha256: ee9c2922e07afc85fc82d5fa82c9ac2c79da3be3283a5e17bf2f2ae38dbd02fb @@ -3901,6 +4046,16 @@ packages: run_exports: {} size: 21561 timestamp: 1774492402955 +- conda: https://prefix.dev/conda-forge/noarch/types-pyyaml-6.0.12.20260906-pyh5ded981_0.conda + sha256: f03e3fd7c700ae031261a1c63ff9936f94b182cfd31b425692432f924b2fc1b6 + md5: a7974f6a351b4a33cf188dd50d72dc63 + depends: + - python >=3.11 + - python + license: Apache-2.0 AND MIT + run_exports: {} + size: 27677 + timestamp: 1788690143833 - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 md5: c70ad746c22219b9700931707482992c diff --git a/pixi.toml b/pixi.toml index e1254dc..20ee5d1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -17,6 +17,8 @@ lint = "pre-commit run --all-files --hook-stage manual" [feature.test.dependencies] pytest = ">=8,<9" pytest-cov = ">=6,<8" +mypy = ">=1.18,<3" +types-pyyaml = ">=6,<7" python-build = ">=1,<2" setuptools = ">=68,<81" wheel = ">=0.45,<1" @@ -25,6 +27,8 @@ pyyaml = ">=6,<7" [feature.test.tasks] test = { cmd = "pytest -p pytest_cov --cov=dynamic_stack_decider/dynamic_stack_decider --cov-report=term-missing", env = { PYTEST_DISABLE_PLUGIN_AUTOLOAD = "1" } } package = "python scripts/check_packages.py" +typecheck = "mypy dynamic_stack_decider/dynamic_stack_decider scripts examples" +demo = { cmd = "python examples/demo.py", env = { PYTHONPATH = "dynamic_stack_decider" } } [feature.ros.dependencies] ros-jazzy-rclpy = ">=7,<8" @@ -32,11 +36,15 @@ ros-jazzy-std-msgs = ">=5,<6" [feature.visualization.dependencies] pyqt = ">=5.15,<6" +pyqt-stubs = ">=5.15,<6" pydot = ">=3,<5" ros-jazzy-qt-dotgraph = "*" ros-jazzy-rqt-gui-py = "*" ros-jazzy-ament-index-python = "*" +[feature.visualization.tasks] +typecheck-gui = "mypy dynamic_stack_decider_visualization/dynamic_stack_decider_visualization" + [feature.visualization.activation.env] QT_QPA_PLATFORM = "offscreen" diff --git a/pyproject.toml b/pyproject.toml index 5e9a60c..cccfd58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,14 @@ select = ["F", "E", "B", "W", "I", "N", "UP"] testpaths = ["dynamic_stack_decider/test", "dynamic_stack_decider_visualization/test"] pythonpath = ["dynamic_stack_decider", "dynamic_stack_decider_visualization"] addopts = "--strict-config --strict-markers -ra" + +[tool.mypy] +python_version = "3.10" +strict = true +mypy_path = ["dynamic_stack_decider", "dynamic_stack_decider_visualization", "stubs"] +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = ["rclpy.*", "std_msgs.*", "ament_index_python.*", "PyQt5.uic"] +ignore_missing_imports = true +follow_imports = "skip" diff --git a/scripts/check_packages.py b/scripts/check_packages.py index a1ca1ee..e9014ed 100644 --- a/scripts/check_packages.py +++ b/scripts/check_packages.py @@ -19,6 +19,7 @@ def main() -> None: with zipfile.ZipFile(wheel) as archive: files = archive.namelist() assert f"{name}/__init__.py" in files + assert f"{name}/py.typed" in files assert not any(path.startswith(("test/", "tests/")) for path in files) assert any(path.endswith("/package.xml") for path in files) if name.endswith("visualization"): diff --git a/stubs/README.md b/stubs/README.md new file mode 100644 index 0000000..e769072 --- /dev/null +++ b/stubs/README.md @@ -0,0 +1,10 @@ +# External typing boundaries + +These stubs describe the Qt/RQT APIs used by the visualization. The installed +ROS packages do not ship typing metadata. Keep signatures aligned with the +RoboStack versions selected in `pixi.lock`. Qt itself uses the `pyqt-stubs` +package and pydot supplies its own annotations. + +ROS client/message imports and `PyQt5.uic` are isolated as untyped third-party +boundaries in the mypy configuration. Library code is checked in strict mode; +there are no per-line type suppressions. diff --git a/stubs/qt_dotgraph/__init__.pyi b/stubs/qt_dotgraph/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/qt_dotgraph/dot_to_qt.pyi b/stubs/qt_dotgraph/dot_to_qt.pyi new file mode 100644 index 0000000..9fa664d --- /dev/null +++ b/stubs/qt_dotgraph/dot_to_qt.pyi @@ -0,0 +1,9 @@ +from PyQt5.QtWidgets import QGraphicsItem, QGraphicsScene + +class EdgeItem: + def add_to_scene(self, scene: QGraphicsScene) -> None: ... + +class DotToQtGenerator: + def dotcode_to_qt_items( + self, dotcode: str, highlight_level: int, *, same_label_siblings: bool + ) -> tuple[dict[str, QGraphicsItem], dict[str, list[EdgeItem]]]: ... diff --git a/stubs/qt_dotgraph/pydotfactory.pyi b/stubs/qt_dotgraph/pydotfactory.pyi new file mode 100644 index 0000000..4ed5cd8 --- /dev/null +++ b/stubs/qt_dotgraph/pydotfactory.pyi @@ -0,0 +1,4 @@ +import pydot + +class PydotFactory: + def create_dot(self, graph: pydot.Dot) -> str: ... diff --git a/stubs/rqt_gui/__init__.pyi b/stubs/rqt_gui/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/rqt_gui/main.pyi b/stubs/rqt_gui/main.pyi new file mode 100644 index 0000000..5d69dda --- /dev/null +++ b/stubs/rqt_gui/main.pyi @@ -0,0 +1,3 @@ +class Main: + def __init__(self, filename: str) -> None: ... + def main(self, *, standalone: str) -> int: ... diff --git a/stubs/rqt_gui_py/__init__.pyi b/stubs/rqt_gui_py/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/rqt_gui_py/plugin.pyi b/stubs/rqt_gui_py/plugin.pyi new file mode 100644 index 0000000..872e598 --- /dev/null +++ b/stubs/rqt_gui_py/plugin.pyi @@ -0,0 +1,13 @@ +from typing import Protocol + +from PyQt5.QtCore import QObject + +class Settings(Protocol): + def set_value(self, key: str, value: object) -> None: ... + def value(self, key: str, default: object) -> object: ... + +class Plugin(QObject): + def __init__(self, context: object) -> None: ... + def save_settings(self, plugin_settings: object, instance_settings: Settings) -> None: ... + def restore_settings(self, plugin_settings: object, instance_settings: Settings) -> None: ... + def shutdown_plugin(self) -> None: ...