Skip to content

Commit

Permalink
feat: Add support for hiding empty plays and plays without roles (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
haidaraM authored Mar 25, 2024
1 parent 9155fa8 commit 21d5410
Show file tree
Hide file tree
Showing 16 changed files with 345 additions and 54 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 20 additions & 1 deletion ansibleplaybookgrapher/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
64 changes: 53 additions & 11 deletions ansibleplaybookgrapher/graph_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"]]:
"""
Expand Down Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions ansibleplaybookgrapher/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions ansibleplaybookgrapher/renderer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
"""
Expand Down
26 changes: 22 additions & 4 deletions ansibleplaybookgrapher/renderer/graphviz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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...")
Expand Down Expand Up @@ -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")
Expand All @@ -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)

Expand Down
Loading

0 comments on commit 21d5410

Please sign in to comment.