Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for hiding empty plays and plays without roles #177

Merged
merged 14 commits into from
Mar 25, 2024
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