diff --git a/README.md b/README.md index daa0be8c..94984615 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ JavaScript: the files for the others nodes. The cursor will be at the task exact position in the file. Lastly, you can provide your own protocol formats with `--open-protocol-handler custom --open-protocol-custom-formats '{}'`. See the help - and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/12cee0fbd59ffbb706731460e301f0b886515357/ansibleplaybookgrapher/graphbuilder.py#L33-L42). + and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/12cee0fbd59ffbb706731460e301f0b886515357/ansibleplaybookgrapher/graphbuilder.py#L33-L42) - Filer tasks based on tags - Export the dot file used to generate the graph with Graphviz. diff --git a/ansibleplaybookgrapher/cli.py b/ansibleplaybookgrapher/cli.py index b6d9d515..550a8396 100644 --- a/ansibleplaybookgrapher/cli.py +++ b/ansibleplaybookgrapher/cli.py @@ -76,6 +76,8 @@ def run(self): output_filename=self.options.output_filename, view=self.options.view, save_dot_file=self.options.save_dot_file, + hide_empty_plays=self.options.hide_empty_plays, + hide_plays_without_roles=self.options.hide_plays_without_roles, ) return output_path @@ -91,6 +93,8 @@ def run(self): view=self.options.view, directive=self.options.renderer_mermaid_directive, orientation=self.options.renderer_mermaid_orientation, + hide_empty_plays=self.options.hide_empty_plays, + hide_plays_without_roles=self.options.hide_plays_without_roles, ) return output_path @@ -114,7 +118,7 @@ def _add_my_options(self): dest="include_role_tasks", action="store_true", default=False, - help="Include the tasks of the role in the graph.", + help="Include the tasks of the roles in the graph. Applied when parsing the playbooks.", ) self.parser.add_argument( @@ -205,6 +209,21 @@ def _add_my_options(self): version=f"{__prog__} {__version__} (with ansible {ansible_version})", ) + self.parser.add_argument( + "--hide-plays-without-roles", + action="store_true", + default=False, + help="Hide the plays that end up with no roles in the graph (after applying the tags filter). " + "Only roles at the play level and include_role as tasks are considered (no import_role).", + ) + + self.parser.add_argument( + "--hide-empty-plays", + action="store_true", + default=False, + help="Hide the plays that end up with no tasks in the graph (after applying the tags filter).", + ) + self.parser.add_argument( "playbook_filenames", help="Playbook(s) to graph", diff --git a/ansibleplaybookgrapher/graph_model.py b/ansibleplaybookgrapher/graph_model.py index 898e6db8..792eb7ca 100644 --- a/ansibleplaybookgrapher/graph_model.py +++ b/ansibleplaybookgrapher/graph_model.py @@ -14,7 +14,7 @@ # along with this program. If not, see . import os from collections import defaultdict -from typing import Dict, List, Set, Type, Tuple, Optional +from typing import Dict, List, Set, Tuple, Optional from ansibleplaybookgrapher.utils import generate_id, get_play_colors @@ -79,7 +79,7 @@ def set_position(self): if self.raw_object and self.raw_object.get_ds(): self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos - def get_first_parent_matching_type(self, node_type: Type) -> Type: + def get_first_parent_matching_type(self, node_type: type) -> type: """ Get the first parent of this node matching the given type :param node_type: The type of the parent to get @@ -164,7 +164,7 @@ def add_node(self, target_composition: str, node: Node): node.index = self._node_counter + 1 self._node_counter += 1 - def get_node(self, target_composition: str) -> List: + def get_nodes(self, target_composition: str) -> List: """ Get a node from the compositions :param target_composition: @@ -221,6 +221,33 @@ def _get_all_links(self, links: Dict[Node, List[Node]]): node._get_all_links(links) links[self].append(node) + def is_empty(self) -> bool: + """ + Returns true if the composite node is empty, false otherwise + :return: + """ + for _, nodes in self._compositions.items(): + if len(nodes) > 0: + return False + + return True + + def has_node_type(self, node_type: type) -> bool: + """ + Returns true if the composite node has at least one node of the given type, false otherwise + :param node_type: The type of the node + :return: + """ + for _, nodes in self._compositions.items(): + for node in nodes: + if isinstance(node, node_type): + return True + + if isinstance(node, CompositeNode): + return node.has_node_type(node_type) + + return False + class CompositeTasksNode(CompositeNode): """ @@ -261,7 +288,7 @@ def tasks(self) -> List[Node]: The tasks attached to this block :return: """ - return self.get_node("tasks") + return self.get_nodes("tasks") class PlaybookNode(CompositeNode): @@ -296,13 +323,24 @@ def set_position(self): self.line = 1 self.column = 1 - @property - def plays(self) -> List["PlayNode"]: + def plays( + self, exclude_empty: bool = False, exclude_without_roles: bool = False + ) -> List["PlayNode"]: """ Return the list of plays + :param exclude_empty: Whether to exclude the empty plays from the result or not + :param exclude_without_roles: Whether to exclude the plays that do not have roles :return: """ - return self.get_node("plays") + plays = self.get_nodes("plays") + + if exclude_empty: + plays = [play for play in plays if not play.is_empty()] + + if exclude_without_roles: + plays = [play for play in plays if play.has_node_type(RoleNode)] + + return plays def roles_usage(self) -> Dict["RoleNode", Set["PlayNode"]]: """ @@ -364,19 +402,23 @@ def __init__( @property def roles(self) -> List["RoleNode"]: - return self.get_node("roles") + """ + Return the roles of the plays. Tasks using "include_role" are NOT returned. + :return: + """ + return self.get_nodes("roles") @property def pre_tasks(self) -> List["Node"]: - return self.get_node("pre_tasks") + return self.get_nodes("pre_tasks") @property def post_tasks(self) -> List["Node"]: - return self.get_node("post_tasks") + return self.get_nodes("post_tasks") @property def tasks(self) -> List["Node"]: - return self.get_node("tasks") + return self.get_nodes("tasks") class BlockNode(CompositeTasksNode): diff --git a/ansibleplaybookgrapher/parser.py b/ansibleplaybookgrapher/parser.py index 97763596..f29ceffd 100644 --- a/ansibleplaybookgrapher/parser.py +++ b/ansibleplaybookgrapher/parser.py @@ -353,6 +353,16 @@ def _include_tasks_in_blocks( # See :func:`~ansible.playbook.included_file.IncludedFile.process_include_results` from line 155 display.v(f"An 'include_role' found: '{task_or_block.get_name()}'") + if not task_or_block.evaluate_tags( + only_tags=self.tags, + skip_tags=self.skip_tags, + all_vars=task_vars, + ): + display.vv( + f"The include_role '{task_or_block.get_name()}' is skipped due to the tags." + ) + continue # Go to the next task + # Here we are using the role name instead of the task name to keep the same behavior as a # traditional role if self.group_roles_by_name: diff --git a/ansibleplaybookgrapher/renderer/__init__.py b/ansibleplaybookgrapher/renderer/__init__.py index 5f32ce9c..c5cd8ee2 100644 --- a/ansibleplaybookgrapher/renderer/__init__.py +++ b/ansibleplaybookgrapher/renderer/__init__.py @@ -57,16 +57,20 @@ def render( open_protocol_custom_formats: Dict[str, str], output_filename: str, view: bool, + hide_empty_plays: bool = False, + hide_plays_without_roles: bool = False, **kwargs, ) -> str: """ Render the playbooks to a file. :param open_protocol_handler: The protocol handler name to use :param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom - :param output_filename: without any extension + :param output_filename: The output filename without any extension :param view: Whether to open the rendered file in the default viewer + :param hide_empty_plays: Whether to hide empty plays or not when rendering the graph + :param hide_plays_without_roles: Whether to hide plays without any roles or not :param kwargs: - :return: The filename of the rendered file + :return: The path of the rendered file """ pass @@ -128,9 +132,16 @@ def build_node(self, node: Node, color: str, fontcolor: str, **kwargs): ) @abstractmethod - def build_playbook(self, **kwargs) -> str: + def build_playbook( + self, + hide_empty_plays: bool = False, + hide_plays_without_roles: bool = False, + **kwargs, + ) -> str: """ Build the whole playbook + :param hide_empty_plays: Whether to hide empty plays or not + :param hide_plays_without_roles: :param kwargs: :return: The rendered playbook as a string """ diff --git a/ansibleplaybookgrapher/renderer/graphviz/__init__.py b/ansibleplaybookgrapher/renderer/graphviz/__init__.py index 831897cd..b410c288 100644 --- a/ansibleplaybookgrapher/renderer/graphviz/__init__.py +++ b/ansibleplaybookgrapher/renderer/graphviz/__init__.py @@ -53,10 +53,18 @@ def render( open_protocol_custom_formats: Dict[str, str], output_filename: str, view: bool, + hide_empty_plays: bool = False, + hide_plays_without_roles=False, **kwargs, ) -> str: """ - :return: The filename where the playbooks where rendered + :param open_protocol_handler: The protocol handler name to use + :param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom + :param output_filename: The output filename without any extension + :param view: Whether to open the rendered file in the default viewer + :param hide_empty_plays: Whether to hide empty plays or not when rendering the graph + :param hide_plays_without_roles: Whether to hide plays without any roles or not + :return: The path of the rendered file """ save_dot_file = kwargs.get("save_dot_file", False) @@ -76,7 +84,10 @@ def render( roles_built=roles_built, digraph=digraph, ) - builder.build_playbook() + builder.build_playbook( + hide_empty_plays=hide_empty_plays, + hide_plays_without_roles=hide_plays_without_roles, + ) roles_built.update(builder.roles_built) display.display("Rendering the graph...") @@ -274,9 +285,13 @@ def build_role(self, role_node: RoleNode, color: str, fontcolor: str, **kwargs): digraph=role_subgraph, ) - def build_playbook(self, **kwargs) -> str: + def build_playbook( + self, hide_empty_plays: bool = False, hide_plays_without_roles=False, **kwargs + ) -> str: """ Convert the PlaybookNode to the graphviz dot format + :param hide_empty_plays: Whether to hide empty plays or not when rendering the graph + :param hide_plays_without_roles: Whether to hide plays without any roles or not :return: The text representation of the graphviz dot format for the playbook """ display.vvv(f"Converting the graph to the dot format for graphviz") @@ -289,7 +304,10 @@ def build_playbook(self, **kwargs) -> str: URL=self.get_node_url(self.playbook_node, "file"), ) - for play in self.playbook_node.plays: + for play in self.playbook_node.plays( + exclude_empty=hide_empty_plays, + exclude_without_roles=hide_plays_without_roles, + ): with self.digraph.subgraph(name=play.name) as play_subgraph: self.build_play(play, digraph=play_subgraph, **kwargs) diff --git a/ansibleplaybookgrapher/renderer/mermaid.py b/ansibleplaybookgrapher/renderer/mermaid.py index 677de1ae..c24a33de 100644 --- a/ansibleplaybookgrapher/renderer/mermaid.py +++ b/ansibleplaybookgrapher/renderer/mermaid.py @@ -49,26 +49,32 @@ def render( open_protocol_custom_formats: Dict[str, str], output_filename: str, view: bool, + hide_empty_plays: bool = False, + hide_plays_without_roles: bool = False, + directive: str = DEFAULT_DIRECTIVE, + orientation: str = DEFAULT_ORIENTATION, **kwargs, ) -> str: """ - :param open_protocol_handler: - :param open_protocol_custom_formats: - :param output_filename: without any extension - :param view: + :param open_protocol_handler: Not supported for the moment + :param open_protocol_custom_formats: Not supported for the moment + :param output_filename: The output filename without any extension + :param view: Not supported for the moment + :param hide_empty_plays: Whether to hide empty plays or not when rendering the graph + :param hide_plays_without_roles: Whether to hide plays without any roles or not + :param directive: Mermaid directive + :param orientation: Mermaid graph orientation :param kwargs: :return: """ # TODO: Add support for protocol handler # TODO: Add support for hover + mermaid_code = "---\n" mermaid_code += "title: Ansible Playbook Grapher\n" mermaid_code += "---\n" - directive = kwargs.get("directive", DEFAULT_DIRECTIVE) - orientation = kwargs.get("orientation", DEFAULT_ORIENTATION) - display.vvv(f"Using '{directive}' as directive for the mermaid chart") mermaid_code += f"{directive}\n" @@ -90,7 +96,10 @@ def render( link_order=link_order, ) - mermaid_code += playbook_builder.build_playbook() + mermaid_code += playbook_builder.build_playbook( + hide_empty_plays=hide_empty_plays, + hide_plays_without_roles=hide_plays_without_roles, + ) link_order = playbook_builder.link_order roles_built.update(playbook_builder.roles_built) @@ -130,15 +139,24 @@ def __init__( roles_usage, roles_built, ) + self.mermaid_code = "" # Used as an identifier for the links self.link_order = link_order # The current depth level of the nodes. Used for indentation - self._identation_level = 1 + self._indentation_level = 1 - def build_playbook(self, **kwargs) -> str: + def build_playbook( + self, + hide_empty_plays: bool = False, + hide_plays_without_roles=False, + **kwargs: bool, + ) -> str: """ Build the playbook + :param hide_plays_without_roles: Whether to hide plays without any roles or not + :param hide_empty_plays: Whether to hide empty plays or not + :param hide_plays_without_roles: Whether to hide plays without any roles or not :param kwargs: :return: """ @@ -150,10 +168,13 @@ def build_playbook(self, **kwargs) -> str: self.add_comment(f"Start of the playbook '{self.playbook_node.name}'") self.add_text(f'{self.playbook_node.id}("{self.playbook_node.name}")') - self._identation_level += 1 - for play_node in self.playbook_node.plays: + self._indentation_level += 1 + for play_node in self.playbook_node.plays( + exclude_empty=hide_empty_plays, + exclude_without_roles=hide_plays_without_roles, + ): self.build_play(play_node) - self._identation_level -= 1 + self._indentation_level -= 1 self.add_comment(f"End of the playbook '{self.playbook_node.name}'\n") @@ -182,9 +203,9 @@ def build_play(self, play_node: PlayNode, **kwargs): ) # traverse the play - self._identation_level += 1 + self._indentation_level += 1 self.traverse_play(play_node) - self._identation_level -= 1 + self._indentation_level -= 1 self.add_comment(f"End of the play '{play_node.name}'") @@ -249,14 +270,14 @@ def build_role(self, role_node: RoleNode, color: str, fontcolor: str, **kwargs): ) # Role tasks - self._identation_level += 1 + self._indentation_level += 1 for role_task in role_node.tasks: self.build_node( node=role_task, color=node_color, fontcolor=fontcolor, ) - self._identation_level -= 1 + self._indentation_level -= 1 self.add_comment(f"End of the role '{role_node.name}'") @@ -287,14 +308,14 @@ def build_block(self, block_node: BlockNode, color: str, fontcolor: str, **kwarg self.add_text(f'subgraph subgraph_{block_node.id}["{block_node.name} "]') - self._identation_level += 1 + self._indentation_level += 1 for task in block_node.tasks: self.build_node( node=task, color=color, fontcolor=fontcolor, ) - self._identation_level -= 1 + self._indentation_level -= 1 self.add_text("end") # End of the subgraph self.add_comment(f"End of the block '{block_node.name}'") @@ -347,4 +368,4 @@ def indentation(self) -> str: Return the current indentation level as tabulations :return: """ - return "\t" * self._identation_level + return "\t" * self._indentation_level diff --git a/ansibleplaybookgrapher/utils.py b/ansibleplaybookgrapher/utils.py index e460a1f6..2298748a 100644 --- a/ansibleplaybookgrapher/utils.py +++ b/ansibleplaybookgrapher/utils.py @@ -21,7 +21,7 @@ from typing import Tuple, List, Dict, Any, Set from ansible.errors import AnsibleError -from ansible.module_utils._text import to_text +from ansible.module_utils.common.text.converters import to_text from ansible.parsing.dataloader import DataLoader from ansible.playbook.role_include import IncludeRole from ansible.playbook.task import Task diff --git a/tests/fixtures/import_role.yml b/tests/fixtures/import_role.yml index 7f9f5579..136e40fa 100644 --- a/tests/fixtures/import_role.yml +++ b/tests/fixtures/import_role.yml @@ -1,7 +1,7 @@ --- - hosts: all tags: - - play2 + - play1 roles: - role: fake_role tags: diff --git a/tests/fixtures/include_role.yml b/tests/fixtures/include_role.yml index 04c12b6d..e6220932 100644 --- a/tests/fixtures/include_role.yml +++ b/tests/fixtures/include_role.yml @@ -1,7 +1,7 @@ --- - hosts: all tags: - - play2 + - play1 roles: - fake_role - display_some_facts diff --git a/tests/fixtures/play-hiding.yml b/tests/fixtures/play-hiding.yml new file mode 100644 index 00000000..66a07b4e --- /dev/null +++ b/tests/fixtures/play-hiding.yml @@ -0,0 +1,22 @@ +--- +- hosts: host1 + tags: + - play1 + roles: + - role: with-dependencies + + +- hosts: host2 + tags: + - play2 + tasks: + - name: debug + debug: msg="Post task 1" + - name: Include role + include_role: + name: fake_role + + +- hosts: host3 # This should not be displayed when --hide-empty-plays is set + tags: + - play3 diff --git a/tests/fixtures/roles_dependencies.yml b/tests/fixtures/roles_dependencies.yml index f8b55cd0..aff41b88 100644 --- a/tests/fixtures/roles_dependencies.yml +++ b/tests/fixtures/roles_dependencies.yml @@ -1,7 +1,7 @@ --- - hosts: all tags: - - play2 + - play1 roles: - role: with-dependencies diff --git a/tests/test_graph_model.py b/tests/test_graph_model.py index 86bc9882..b7fb62d5 100644 --- a/tests/test_graph_model.py +++ b/tests/test_graph_model.py @@ -66,3 +66,36 @@ def test_get_all_tasks_nodes(): all_tasks = play.get_all_tasks() assert len(all_tasks) == 4, "There should be 4 tasks in all" assert [task_1, task_2, task_3, task_4] == all_tasks + + +def test_empty_play(): + """ + Testing the emptiness of a play + :return: + """ + + play = PlayNode("play") + assert play.is_empty(), "The play should empty" + + play.add_node("roles", RoleNode("my_role_1")) + assert not play.is_empty(), "The play should not be empty" + + +def test_has_node_type(): + """ + Testing the method has_node_type + :return: + """ + play = PlayNode("play") + block = BlockNode("block 1") + role = RoleNode("my_role") + role.add_node("tasks", TaskNode("task 1")) + + block.add_node("tasks", role) + play.add_node("tasks", block) + + assert play.has_node_type(BlockNode), "The play should have BlockNode" + assert play.has_node_type(RoleNode), "The play should have a RoleNode" + assert play.has_node_type(TaskNode), "The play should have a TaskNode" + + assert not role.has_node_type(BlockNode), "The role doesn't have a BlockNode" diff --git a/tests/test_graphviz_renderer.py b/tests/test_graphviz_renderer.py index ebb63679..9594d35e 100644 --- a/tests/test_graphviz_renderer.py +++ b/tests/test_graphviz_renderer.py @@ -541,3 +541,118 @@ def test_group_roles_by_name( post_tasks_number=post_tasks_number, blocks_number=1, ) + + +def test_hiding_plays(request): + """ + Test hiding_plays with the flag --hide-empty-plays. + + This case is about hiding plays with 0 zero task (no filtering) + :param request: + :return: + """ + svg_path, playbook_paths = run_grapher( + ["play-hiding.yml"], + output_filename=request.node.name, + additional_args=["--hide-empty-plays"], + ) + + _common_tests( + svg_path=svg_path, + playbook_paths=playbook_paths, + plays_number=2, + roles_number=2, + tasks_number=1, + ) + + +def test_hiding_empty_plays_with_tags_filter(request): + """ + Test hiding plays with the flag --hide-empty-plays. + + This case is about hiding plays when filtering with tags + :param request: + :return: + """ + svg_path, playbook_paths = run_grapher( + ["play-hiding.yml"], + output_filename=request.node.name, + additional_args=["--hide-empty-plays", "--tags", "play1"], + ) + + _common_tests( + svg_path=svg_path, playbook_paths=playbook_paths, plays_number=1, roles_number=1 + ) + + +def test_hiding_empty_plays_with_tags_filter_all(request): + """ + Test hiding plays with the flag --hide-empty-plays. + + This case is about hiding ALL the plays when filtering with tags + :param request: + :return: + """ + svg_path, playbook_paths = run_grapher( + ["play-hiding.yml"], + output_filename=request.node.name, + additional_args=[ + "--hide-empty-plays", + "--tags", + "fake-tag-that-does-not-exist", + ], + ) + + _common_tests(svg_path=svg_path, playbook_paths=playbook_paths) + + +def test_hiding_plays_without_roles(request): + """ + Test hiding plays with the flag --hide-plays-without-roles + + :param request: + :return: + """ + svg_path, playbook_paths = run_grapher( + ["play-hiding.yml"], + output_filename=request.node.name, + additional_args=[ + "--hide-plays-without-roles", + ], + ) + + _common_tests( + svg_path=svg_path, + playbook_paths=playbook_paths, + plays_number=2, + roles_number=2, + tasks_number=1, + ) + + +def test_hiding_plays_without_roles_with_tags_filtering(request): + """ + Test hiding plays with the flag --hide-plays-without-roles + + Also apply some tags filter + :param request: + :return: + """ + svg_path, playbook_paths = run_grapher( + ["play-hiding.yml"], + output_filename=request.node.name, + additional_args=[ + "--hide-plays-without-roles", + "--tags", + "play1", + "--include-role-tasks", + ], + ) + + _common_tests( + svg_path=svg_path, + playbook_paths=playbook_paths, + plays_number=1, + roles_number=1, + tasks_number=5, + ) diff --git a/tests/test_mermaid_renderer.py b/tests/test_mermaid_renderer.py index 346836aa..a4c41260 100644 --- a/tests/test_mermaid_renderer.py +++ b/tests/test_mermaid_renderer.py @@ -50,7 +50,7 @@ def run_grapher( def _common_tests(mermaid_path: str, playbook_paths: List[str], **kwargs): """ - + Some common tests for mermaid renderer :param mermaid_path: :param playbook_paths: :param kwargs: diff --git a/tests/test_parser.py b/tests/test_parser.py index 80d4d9ab..70929170 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -48,7 +48,7 @@ def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display): """ parser = PlaybookParser(grapher_cli.options.playbook_filenames[0]) playbook_node = parser.parse() - assert len(playbook_node.plays) == 1 + assert len(playbook_node.plays()) == 1 assert playbook_node.path == os.path.join(FIXTURES_PATH, "example.yml") assert playbook_node.line == 1 assert playbook_node.column == 1 @@ -56,7 +56,7 @@ def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display): playbook_node.index is None ), "The index of the playbook should be None (it has no parent)" - play_node = playbook_node.plays[0] + play_node = playbook_node.plays()[0] assert play_node.path == os.path.join(FIXTURES_PATH, "example.yml") assert play_node.line == 2 assert play_node.index == 1 @@ -90,8 +90,8 @@ def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI): """ parser = PlaybookParser(grapher_cli.options.playbook_filenames[0]) playbook_node = parser.parse() - assert len(playbook_node.plays) == 1 - play_node = playbook_node.plays[0] + assert len(playbook_node.plays()) == 1 + play_node = playbook_node.plays()[0] assert play_node.index == 1 assert len(play_node.roles) == 2 @@ -127,8 +127,8 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys): grapher_cli.options.playbook_filenames[0], include_role_tasks=True ) playbook_node = parser.parse() - assert len(playbook_node.plays) == 1 - play_node = playbook_node.plays[0] + assert len(playbook_node.plays()) == 1 + play_node = playbook_node.plays()[0] tasks = play_node.tasks assert len(tasks) == 6 @@ -194,9 +194,9 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI): grapher_cli.options.playbook_filenames[0], include_role_tasks=True ) playbook_node = parser.parse() - assert len(playbook_node.plays) == 1 + assert len(playbook_node.plays()) == 1 - play_node = playbook_node.plays[0] + play_node = playbook_node.plays()[0] pre_tasks = play_node.pre_tasks tasks = play_node.tasks post_tasks = play_node.post_tasks @@ -360,7 +360,7 @@ def test_roles_dependencies(grapher_cli: PlaybookGrapherCLI): grapher_cli.options.playbook_filenames[0], include_role_tasks=True ) playbook_node = parser.parse() - roles = playbook_node.plays[0].roles + roles = playbook_node.plays()[0].roles assert len(roles) == 1, "Only one explicit role is called inside the playbook" role_with_dependencies = roles[0] tasks = role_with_dependencies.tasks