feat: Open node file when double-clicking on it from a browser (#79)

This commit is contained in:
Mohamed El Mouctar Haidara 2022-01-29 14:47:09 +01:00 committed by GitHub
parent 2ce66443dc
commit 826c6ef284
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 394 additions and 76 deletions

View file

@ -1,6 +1,10 @@
# 1.1.0 (unreleased)
- fix: Do not pass display as param since it's a singleton + init locale to fix warning
- feat: Open node file when double-clicking on it from a
browser [\#79](https://github.com/haidaraM/ansible-playbook-grapher/pull/79)
- **Full Changelog**: https://github.com/haidaraM/ansible-playbook-grapher/compare/v1.0.2...v1.1.0
# 1.0.2 (2022-01-16)

View file

@ -61,13 +61,11 @@ regarding the blocks.
The available options:
```
$ ansible-playbook-grapher --help
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY]
[--include-role-tasks] [-s] [--view]
[-o OUTPUT_FILENAME] [--version] [-t TAGS]
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
[-e EXTRA_VARS]
ansible-playbook-grapher --help
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY] [--include-role-tasks] [-s] [--view] [-o OUTPUT_FILENAME]
[--open-protocol-handler {default,vscode,custom}] [--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS] [--version]
[-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
playbook
Make graphs from your Ansible Playbooks.
@ -79,29 +77,32 @@ optional arguments:
--ask-vault-password, --ask-vault-pass
ask for vault password
--include-role-tasks Include the tasks of the role in the graph.
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
The custom formats to use as URLs for the nodes in the graph. Required if --open-protocol-handler is set to custom. You should
provide a JSON formatted string like: {"file": "", "folder": ""}. Example: If you want to open folders (roles) inside the browser
and files (tasks) in vscode, set this to '{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
--open-protocol-handler {default,vscode,custom}
The protocol to use to open the nodes when double-clicking on them in your SVG viewer. Your SVG viewer must support double-click
and Javascript. The supported values are 'default', 'vscode' and 'custom'. For 'default', the URL will be the path to the file or
folders. When using a browser, it will open or download them. For 'vscode', the folders and files will be open with VSCode. For
'custom', you need to set a custom format with --open-protocol-custom-formats.
--skip-tags SKIP_TAGS
only run plays and tasks whose tags do not match these
values
only run plays and tasks whose tags do not match these values
--vault-id VAULT_IDS the vault identity to use
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
vault password file
--version show program's version number and exit
--view Automatically open the resulting SVG file with your
systems default viewer application for the file type
--view Automatically open the resulting SVG file with your systems default viewer application for the file type
-e EXTRA_VARS, --extra-vars EXTRA_VARS
set additional variables as key=value or YAML/JSON, if
filename prepend with @
set additional variables as key=value or YAML/JSON, if filename prepend with @
-h, --help show this help message and exit
-i INVENTORY, --inventory INVENTORY
specify inventory host path or comma separated host
list.
specify inventory host path or comma separated host list.
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
Output filename without the '.svg' extension. Default:
<playbook>.svg
Output filename without the '.svg' extension. Default: <playbook>.svg
-s, --save-dot-file Save the dot file used to generate the graph.
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
-v, --verbose verbose mode (-vvv for more, -vvvv to enable
connection debugging)
-v, --verbose verbose mode (-vvv for more, -vvvv to enable connection debugging)
```
## Configuration: ansible.cfg

View file

@ -12,6 +12,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import ntpath
import os
import sys
@ -19,13 +20,18 @@ from abc import ABC
from ansible.cli import CLI
from ansible.cli.arguments import option_helpers
from ansible.errors import AnsibleOptionsError
from ansible.release import __version__ as ansible_version
from ansible.utils.display import Display, initialize_locale
from ansibleplaybookgrapher import __prog__, __version__
from ansibleplaybookgrapher.parser import PlaybookParser
from ansibleplaybookgrapher.postprocessor import GraphVizPostProcessor
from ansibleplaybookgrapher.renderer import GraphvizRenderer
from ansibleplaybookgrapher.renderer import GraphvizRenderer, OPEN_PROTOCOL_HANDLERS
# The display is a singleton. This instruction will NOT return a new instance.
# We explicitly set the verbosity after the init.
display = Display()
def get_cli_class():
@ -45,9 +51,6 @@ class GrapherCLI(CLI, ABC):
def run(self):
super(GrapherCLI, self).run()
# The display is a singleton. This instruction will NOT return a new instance.
# We explicitly set the verbosity after the init.
display = Display()
# Required to fix the warning "ansible.utils.display.initialize_locale has not been called..."
initialize_locale()
display.verbosity = self.options.verbosity
@ -57,7 +60,8 @@ class GrapherCLI(CLI, ABC):
include_role_tasks=self.options.include_role_tasks)
playbook_node = parser.parse()
renderer = GraphvizRenderer(playbook_node)
renderer = GraphvizRenderer(playbook_node, open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats)
svg_path = renderer.render(self.options.output_filename, self.options.save_dot_file, self.options.view)
post_processor = GraphVizPostProcessor(svg_path=svg_path)
@ -86,7 +90,6 @@ class PlaybookGrapherCLI(GrapherCLI):
def _add_my_options(self):
"""
Add some of my options to the parser
:param parser:
:return:
"""
self.parser.prog = __prog__
@ -106,6 +109,26 @@ class PlaybookGrapherCLI(GrapherCLI):
self.parser.add_argument("-o", "--output-file-name", dest='output_filename',
help="Output filename without the '.svg' extension. Default: <playbook>.svg")
self.parser.add_argument("--open-protocol-handler", dest="open_protocol_handler",
choices=list(OPEN_PROTOCOL_HANDLERS.keys()), default="default",
help="""The protocol to use to open the nodes when double-clicking on them in your SVG
viewer. Your SVG viewer must support double-click and Javascript.
The supported values are 'default', 'vscode' and 'custom'.
For 'default', the URL will be the path to the file or folders. When using a browser,
it will open or download them.
For 'vscode', the folders and files will be open with VSCode.
For 'custom', you need to set a custom format with --open-protocol-custom-formats.
""")
self.parser.add_argument("--open-protocol-custom-formats", dest="open_protocol_custom_formats", default=None,
help="""The custom formats to use as URLs for the nodes in the graph. Required if
--open-protocol-handler is set to custom.
You should provide a JSON formatted string like: {"file": "", "folder": ""}.
Example: If you want to open folders (roles) inside the browser and files (tasks) in
vscode, set this to
'{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
""")
self.parser.add_argument('--version', action='version',
version="%s %s (with ansible %s)" % (__prog__, __version__, ansible_version))
@ -132,8 +155,36 @@ class PlaybookGrapherCLI(GrapherCLI):
# use the playbook name (without the extension) as output filename
self.options.output_filename = os.path.splitext(ntpath.basename(self.options.playbook_filename))[0]
if self.options.open_protocol_handler == "custom":
self.validate_open_protocol_custom_formats()
return options
def validate_open_protocol_custom_formats(self):
"""
Validate the provided open protocol format
:return:
"""
error_msg = 'Make sure to provide valid formats. Example: {"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
format_str = self.options.open_protocol_custom_formats
if not format_str:
raise AnsibleOptionsError("When the protocol handler is to set to custom, you must provide the formats to "
"use with --open-protocol-custom-formats.")
try:
format_dict = json.loads(format_str)
except Exception as e:
display.error(f"{type(e).__name__} when reading the provided formats '{format_str}': {e}")
display.error(error_msg)
sys.exit(1)
if "file" not in format_dict or "folder" not in format_dict:
display.error(f"The field 'file' or 'folder' is missing from the provided format '{format_str}'")
display.error(error_msg)
sys.exit(1)
# Replace the string with a dict
self.options.open_protocol_custom_formats = format_dict
def main(args=None):
args = args or sys.argv

View file

@ -52,7 +52,7 @@ function unHighlightLinkedNodes(rootElement, isHover) {
// Recursively unhighlight
unHighlightLinkedNodes(linkedElement, isHover);
}
})
}
@ -60,7 +60,7 @@ function unHighlightLinkedNodes(rootElement, isHover) {
/**
* Hover handler for mouseenter event
* @param event
* @param {Event} event
*/
function hoverMouseEnter(event) {
highlightLinkedNodes(event.currentTarget);
@ -68,7 +68,7 @@ function hoverMouseEnter(event) {
/**
* Hover handler for mouseleave event
* @param event
* @param {Event} event
*/
function hoverMouseLeave(event) {
unHighlightLinkedNodes(event.currentTarget, true);
@ -76,10 +76,12 @@ function hoverMouseLeave(event) {
/**
* Handler when clicking on some elements
* @param event
* @param {Event} event
*/
function clickOnElement(event) {
let newClickedElement = $(event.currentTarget);
const newClickedElement = $(event.currentTarget);
event.preventDefault(); // Disable the default click behavior since we override it here
if (newClickedElement.attr('id') === $(currentSelectedElement).attr('id')) { // clicking again on the same element
newClickedElement.removeClass(HIGHLIGHT_CLASS);
@ -87,7 +89,7 @@ function clickOnElement(event) {
currentSelectedElement = null;
} else { // clicking on a different node
// Remove highlight from all the nodes linked the current selected node
// Remove highlight from all the nodes linked to the current selected node
unHighlightLinkedNodes(currentSelectedElement, false);
if (currentSelectedElement) {
currentSelectedElement.removeClass(HIGHLIGHT_CLASS);
@ -99,22 +101,51 @@ function clickOnElement(event) {
}
}
/**
* Handler when double clicking on some elements
* @param {Event} event
*/
function dblClickElement(event) {
const newElementDlbClicked = event.currentTarget;
const links = $(newElementDlbClicked).find("a[xlink\\:href]");
if (links.length > 0) {
const targetLink = $(links[0]).attr("xlink:href");
document.location = targetLink;
} else {
console.log("No links found on this element");
}
}
$("#svg").ready(function () {
let playbook = $("g[id^=playbook_]");
let plays = $("g[id^=play_]");
let roles = $("g[id^=role_]");
let blocks = $("g[id^=block_]");
let tasks = $("g[id^=pre_task_], g[id^=task_], g[id^=post_task_]");
playbook.click(clickOnElement);
playbook.dblclick(dblClickElement);
// Set hover and click events on the plays
plays.hover(hoverMouseEnter, hoverMouseLeave);
plays.click(clickOnElement);
plays.dblclick(dblClickElement);
// Set hover and click events on the roles
roles.hover(hoverMouseEnter, hoverMouseLeave);
roles.click(clickOnElement);
roles.dblclick(dblClickElement);
// Set hover and click events on the blocks
blocks.hover(hoverMouseEnter, hoverMouseLeave);
blocks.click(clickOnElement);
blocks.dblclick(dblClickElement);
// Set hover and click events on the tasks
tasks.hover(hoverMouseEnter, hoverMouseLeave);
tasks.click(clickOnElement);
tasks.dblclick(dblClickElement);
});

View file

@ -12,6 +12,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from collections import defaultdict
from typing import Dict, List, ItemsView
@ -28,12 +29,26 @@ class Node:
:param node_name: The name of the node
:param node_id: An identifier for this node
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
Ansible side
"""
self.name = node_name
self.id = node_id
self.raw_object = raw_object
# Trying to get the object position in the parsed files. Format: (path,line,column)
self.path = self.line = self.column = None
self.retrieve_position()
def retrieve_position(self):
"""
Set the path of this based on the raw object. Not all objects have path
:return:
"""
if self.raw_object and self.raw_object.get_ds():
self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos
def __str__(self):
return f"{type(self).__name__}(name='{self.name}')"
def __repr__(self):
return f"{type(self).__name__}(id='{self.id}',name='{self.name}')"
@ -142,6 +157,16 @@ class PlaybookNode(CompositeNode):
super().__init__(node_name, node_id or generate_id("playbook_"), raw_object=raw_object,
supported_compositions=["plays"])
def retrieve_position(self):
"""
Playbooks only have path as position
:return:
"""
# Since the playbook is the whole file, the set the position as the beginning of the file
self.path = os.path.join(os.getcwd(), self.name)
self.line = 1
self.column = 1
@property
def plays(self) -> List['EdgeNode']:
"""
@ -252,6 +277,12 @@ class TaskNode(Node):
"""
def __init__(self, node_name: str, node_id: str = None, raw_object=None):
"""
:param node_name:
:param node_id:
:param raw_object:
"""
super().__init__(node_name, node_id or generate_id("task_"), raw_object)
@ -260,8 +291,19 @@ class RoleNode(CompositeTasksNode):
A role node. A role is a composition of tasks
"""
def __init__(self, node_name: str, node_id: str = None, raw_object=None):
def __init__(self, node_name: str, node_id: str = None, raw_object=None, include_role: bool = False):
"""
:param node_name:
:param node_id:
:param raw_object:
"""
super().__init__(node_name, node_id or generate_id("role_"), raw_object=raw_object)
self.include_role = include_role
if raw_object and not include_role:
# If it's not an include_role, we take the role path which the path to the folder where the role is located
# on the disk
self.path = raw_object._role_path
def _get_all_tasks_nodes(composite: CompositeNode, task_acc: List[TaskNode]):

View file

@ -95,8 +95,8 @@ class BaseParser(ABC):
task_name = clean_name(self.template(task.get_name(), task_vars))
edge_label = convert_when_to_str(task.when)
edge_node = EdgeNode(parent_node, TaskNode(task_name, generate_id(f"{node_type}_"), raw_object=task),
edge_label)
edge_node = EdgeNode(source=parent_node, node_name=edge_label,
destination=TaskNode(task_name, generate_id(f"{node_type}_"), raw_object=task))
parent_node.add_node(target_composition=f"{node_type}s", node=edge_node)
return True
@ -120,9 +120,6 @@ class PlaybookParser(BaseParser):
self.include_role_tasks = include_role_tasks
self.playbook_filename = playbook_filename
self.playbook = None
# the root node
self.playbook_root_node = None
def parse(self, *args, **kwargs) -> PlaybookNode:
"""
@ -138,17 +135,19 @@ class PlaybookParser(BaseParser):
add post_tasks
:return:
"""
self.playbook = Playbook.load(self.playbook_filename, loader=self.data_loader,
variable_manager=self.variable_manager)
self.playbook_root_node = PlaybookNode(self.playbook_filename)
playbook = Playbook.load(self.playbook_filename, loader=self.data_loader,
variable_manager=self.variable_manager)
# the root node
playbook_root_node = PlaybookNode(self.playbook_filename, raw_object=playbook)
# loop through the plays
for play in self.playbook.get_plays():
for play in playbook.get_plays():
# the load basedir is relative to the playbook path
if play._included_path is not None:
self.data_loader.set_basedir(play._included_path)
else:
self.data_loader.set_basedir(self.playbook._basedir)
self.data_loader.set_basedir(playbook._basedir)
display.vvv(f"Loader basedir set to {self.data_loader.get_basedir()}")
play_vars = self.variable_manager.get_vars(play)
@ -159,7 +158,7 @@ class PlaybookParser(BaseParser):
display.banner("Parsing " + play_name)
play_node = PlayNode(play_name, hosts=play_hosts, raw_object=play)
self.playbook_root_node.add_play(play_node, "")
playbook_root_node.add_play(play_node, "")
# loop through the pre_tasks
display.v("Parsing pre_tasks...")
@ -182,7 +181,7 @@ class PlaybookParser(BaseParser):
# Go to the next role
continue
role_node = RoleNode(clean_name(role.get_name()))
role_node = RoleNode(clean_name(role.get_name()), raw_object=role)
# edge from play to role
play_node.add_node("roles", EdgeNode(play_node, role_node))
@ -215,7 +214,7 @@ class PlaybookParser(BaseParser):
display.display("") # just an empty line
# moving to the next play
return self.playbook_root_node
return playbook_root_node
def _include_tasks_in_blocks(self, current_play: Play, parent_nodes: List[CompositeNode],
block: Union[Block, TaskInclude], node_type: str, play_vars: Dict = None):
@ -258,7 +257,7 @@ class PlaybookParser(BaseParser):
# See :func:`~ansible.playbook.included_file.IncludedFile.process_include_results` from line 155
display.v(f"An 'include_role' found. Including tasks from '{task_or_block.get_name()}'")
role_node = RoleNode(task_or_block.get_name(), raw_object=task_or_block)
role_node = RoleNode(task_or_block.get_name(), raw_object=task_or_block, include_role=True)
parent_nodes[-1].add_node(f"{node_type}s", EdgeNode(parent_nodes[-1], role_node,
convert_when_to_str(task_or_block.when)))

View file

@ -19,7 +19,7 @@ from lxml import etree
from ansibleplaybookgrapher.graph import PlaybookNode
JQUERY = 'https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js'
JQUERY = 'https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js'
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
@ -151,8 +151,8 @@ class GraphVizPostProcessor:
text_path.text = text_element.text
text_element.append(text_path)
# Move a little bit the text
text_element.set("dy", "-1%")
# Move a little the text
text_element.set("dy", "-0.5%")
# Remove unnecessary attributes
text_element.attrib.pop("x")
text_element.attrib.pop("y")

View file

@ -13,16 +13,52 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from typing import Dict
from typing import Dict, Optional
from ansible.utils.display import Display
from graphviz import Digraph
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, PlayNode, RoleNode, BlockNode
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, PlayNode, RoleNode, BlockNode, Node
from ansibleplaybookgrapher.utils import get_play_colors
display = Display()
# The supported protocol handlers to open roles and tasks from the viewer
OPEN_PROTOCOL_HANDLERS = {
"default": {
"folder": "{path}",
"file": "{path}"
},
# https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls
"vscode": {
"folder": "vscode://file/{path}",
"file": "vscode://file/{path}:{line}:{column}"
},
# For custom, the formats need to be provided
"custom": {}
}
def get_node_url(protocol_handler: str, node_type: str, node: Node,
open_protocol_custom_formats: Dict = None) -> Optional[str]:
"""
Get the node url based on the chosen protocol
:param node_type: task or role
:param protocol_handler: the protocol handler to use
:param node: the node to get the url for
:param open_protocol_custom_formats: the custom formats to use when protocol handler is custom
:return:
"""
# Merge the provided custom formats with the default formats
all_formats = {**OPEN_PROTOCOL_HANDLERS, **{"custom": open_protocol_custom_formats}}
if node.path:
url = all_formats[protocol_handler][node_type].format(path=node.path, line=node.line,
column=node.column)
display.vvvv(f"Open protocol URL for node {node}: {url}")
return url
return None
class GraphvizRenderer:
"""
@ -31,16 +67,20 @@ class GraphvizRenderer:
DEFAULT_EDGE_ATTR = {"sep": "10", "esep": "5"}
DEFAULT_GRAPH_ATTR = {"ratio": "fill", "rankdir": "LR", "concentrate": "true", "ordering": "in"}
def __init__(self, playbook_node: 'PlaybookNode', graph_format: str = "svg",
graph_attr: Dict = None, edge_attr: Dict = None):
def __init__(self, playbook_node: 'PlaybookNode', open_protocol_handler: str,
open_protocol_custom_formats: Dict = None, graph_format: str = "svg", graph_attr: Dict = None,
edge_attr: Dict = None):
"""
:param playbook_node: Playbook parsed node
:param open_protocol_handler: The protocol handler name to use. See OPEN_PROTOCOL_HANDLERS
:param graph_format: the graph format to render. See https://graphviz.org/docs/outputs/
:param graph_attr: Default graph attributes
:param edge_attr: Default edge attributes
"""
self.playbook_node = playbook_node
self.open_protocol_handler = open_protocol_handler
self.open_protocol_custom_formats = open_protocol_custom_formats
self.digraph = Digraph(format=graph_format,
graph_attr=graph_attr or GraphvizRenderer.DEFAULT_GRAPH_ATTR,
edge_attr=edge_attr or GraphvizRenderer.DEFAULT_EDGE_ATTR)
@ -67,7 +107,9 @@ class GraphvizRenderer:
else:
edge_label = f"{node_counter} {edge.name}"
graph.node(destination_node.id, label=node_label_prefix + destination_node.name, shape=shape,
id=destination_node.id, tooltip=destination_node.name, color=color)
id=destination_node.id, tooltip=destination_node.name, color=color,
URL=get_node_url(self.open_protocol_handler, "file", destination_node,
self.open_protocol_custom_formats))
graph.edge(source_node.id, destination_node.id, label=edge_label, color=color, fontcolor=color, id=edge.id,
tooltip=edge_label, labeltooltip=edge_label)
@ -91,7 +133,9 @@ class GraphvizRenderer:
with graph.subgraph(name=f"cluster_{destination_node.id}") as block_subgraph:
block_subgraph.node(destination_node.id, label=f"[block] {destination_node.name}", shape="box",
id=destination_node.id, tooltip=destination_node.name, color=color,
labeltooltip=destination_node.name)
labeltooltip=destination_node.name,
URL=get_node_url(self.open_protocol_handler, "file", destination_node,
self.open_protocol_custom_formats))
graph.edge(edge.source.id, destination_node.id, label=edge_label, color=color, fontcolor=color,
tooltip=edge_label, id=edge.id, labeltooltip=edge_label)
@ -114,8 +158,14 @@ class GraphvizRenderer:
role = edge.destination # type: RoleNode
role_edge_label = f"{edge_counter} {edge.name}"
if role.include_role: # For include_role, we point to a file
url = get_node_url(self.open_protocol_handler, "file", role, self.open_protocol_custom_formats)
else: # For normal role invocation, we point to the folder
url = get_node_url(self.open_protocol_handler, "folder", role, self.open_protocol_custom_formats)
with self.digraph.subgraph(name=role.name, node_attr={}) as role_subgraph:
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name, color=color)
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name, color=color,
URL=url)
# from parent to role
graph.edge(edge.source.id, role.id, label=role_edge_label, color=color, fontcolor=color, id=edge.id,
tooltip=role_edge_label, labeltooltip=role_edge_label)
@ -131,7 +181,9 @@ class GraphvizRenderer:
"""
display.vvv(f"Converting the graph to the dot format for graphviz")
# root node
self.digraph.node(self.playbook_node.name, style="dotted", id="root_node")
self.digraph.node(self.playbook_node.name, style="dotted", id=self.playbook_node.id,
URL=get_node_url(self.open_protocol_handler, "file", self.playbook_node,
self.open_protocol_custom_formats))
for play_counter, play_edge in enumerate(self.playbook_node.plays, 1):
# noinspection PyTypeChecker
@ -141,7 +193,9 @@ class GraphvizRenderer:
# play node
play_tooltip = ",".join(play.hosts) if len(play.hosts) > 0 else play.name
self.digraph.node(play.id, id=play.id, label=play.name, style="filled", shape="box", color=color,
fontcolor=play_font_color, tooltip=play_tooltip)
fontcolor=play_font_color, tooltip=play_tooltip,
URL=get_node_url(self.open_protocol_handler, "file", play,
self.open_protocol_custom_formats))
# edge from root node to play
playbook_to_play_label = f"{play_counter} {play_edge.name}"
self.digraph.edge(self.playbook_node.name, play.id, id=play_edge.id, label=playbook_to_play_label,

View file

@ -13,4 +13,4 @@
msg: Hello world
- name: Import role
import_role:
name: display_some_facts
name: display_some_facts

View file

@ -23,7 +23,7 @@
when: ansible_distribution == "Debian"
tags:
- role_tag
- role: display_some_facts
tasks:
- name: Add backport {{backport}}
become: yes
@ -32,7 +32,6 @@
filename: "{{backport}}"
state: present
update_cache: yes
- name: Install packages
become: yes
apt:

View file

@ -128,7 +128,7 @@ def test_cli_tags(tags_option, expected):
def test_skip_tags(skip_tags_option, expected):
"""
:param tags_option:
:param skip_tags_option:
:param expected:
:return:
"""
@ -181,3 +181,50 @@ def test_cli_verbosity_options(verbosity, verbosity_number):
cli.parse()
assert cli.options.verbosity == verbosity_number
def test_cli_open_protocol_custom_formats():
"""
The provided format should be converted to a dict
:return:
"""
formats_str = '{"file": "{path}", "folder": "{path}"}'
args = [__prog__, '--open-protocol-handler', 'custom', '--open-protocol-custom-formats', formats_str,
'playbook1.yml']
cli = get_cli_class()(args)
cli.parse()
assert cli.options.open_protocol_custom_formats == {"file": "{path}",
"folder": "{path}"}, "The formats should be converted to json"
def test_cli_open_protocol_custom_formats_not_provided():
"""
The custom formats must be provided when the protocol handler is set to custom
:return:
"""
args = [__prog__, '--open-protocol-handler', 'custom', 'playbook1.yml']
cli = get_cli_class()(args)
with pytest.raises(AnsibleOptionsError) as exception_info:
cli.parse()
assert "you must provide the formats to use with --open-protocol-custom-formats" in exception_info.value.message
@pytest.mark.parametrize("formats, expected_message",
[['invalid_json', "JSONDecodeError"],
['{}', "The field 'file' or 'folder' is missing"]])
def test_cli_open_protocol_custom_formats_invalid_inputs(formats, expected_message, capsys):
"""
The custom formats must be a valid json data
:return:
"""
args = [__prog__, '--open-protocol-handler', 'custom', '--open-protocol-custom-formats', formats, 'playbook1.yml']
cli = get_cli_class()(args)
with pytest.raises(SystemExit) as exception_info:
cli.parse()
error_msg = capsys.readouterr().err
assert expected_message in error_msg

View file

@ -1,3 +1,4 @@
import os
from typing import List
import pytest
@ -6,11 +7,17 @@ from ansible.utils.display import Display
from ansibleplaybookgrapher import PlaybookParser
from ansibleplaybookgrapher.cli import PlaybookGrapherCLI
from ansibleplaybookgrapher.graph import TaskNode, BlockNode, RoleNode, get_all_tasks_nodes, CompositeNode
from tests import FIXTURES_DIR
# This file directory abspath
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
# Fixtures abspath
FIXTURES_PATH = os.path.join(DIR_PATH, FIXTURES_DIR)
def get_all_tasks(composites: List[CompositeNode]) -> List[TaskNode]:
"""
Get all tasks from a list of composite nodes
:param composites:
:return:
"""
@ -22,12 +29,59 @@ def get_all_tasks(composites: List[CompositeNode]) -> List[TaskNode]:
return tasks
@pytest.mark.parametrize('grapher_cli', [["example.yml"]], indirect=True)
def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
"""
Test the parsing of example.yml
:param grapher_cli:
:param display:
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbook_filename)
playbook_node = parser.parse()
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
play_node = playbook_node.plays[0].destination
assert play_node.path == os.path.join(FIXTURES_PATH, "example.yml")
assert play_node.line == 2
pre_tasks = play_node.pre_tasks
tasks = play_node.tasks
post_tasks = play_node.post_tasks
assert len(pre_tasks) == 2
assert len(tasks) == 4
assert len(post_tasks) == 2
@pytest.mark.parametrize('grapher_cli', [["with_roles.yml"]], indirect=True)
def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI):
"""
Test the parsing of with_roles.yml
:param grapher_cli:
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbook_filename)
playbook_node = parser.parse()
assert len(playbook_node.plays) == 1
play_node = playbook_node.plays[0].destination
assert len(play_node.roles) == 2
fake_role = play_node.roles[0].destination
assert isinstance(fake_role, RoleNode)
assert not fake_role.include_role
assert fake_role.path == os.path.join(FIXTURES_PATH, "roles", "fake_role")
assert fake_role.line is None
assert fake_role.column is None
@pytest.mark.parametrize('grapher_cli', [["include_role.yml"]], indirect=True)
def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display, capsys):
def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys):
"""
Test parsing of include_role
:param grapher_cli:
:param display:
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbook_filename, include_role_tasks=True)
@ -44,6 +98,9 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
# first include_role
include_role_1 = tasks[0].destination
assert isinstance(include_role_1, RoleNode)
assert include_role_1.include_role
assert include_role_1.path == os.path.join(FIXTURES_PATH, "include_role.yml")
assert include_role_1.line == 6
assert len(include_role_1.tasks) == 0, "We don't support adding tasks from include_role with loop"
# first task
@ -53,6 +110,7 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
# second include_role
include_role_2 = tasks[2].destination
assert isinstance(include_role_2, RoleNode)
assert include_role_2.include_role
assert len(include_role_2.tasks) == 3
# second task
@ -62,20 +120,21 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, display: Display,
include_role_3 = tasks[4].destination
assert tasks[4].name == "[when: x is not defined]"
assert isinstance(include_role_3, RoleNode)
assert include_role_3.include_role
assert len(include_role_3.tasks) == 3
# fourth include_role
include_role_4 = tasks[5].destination
assert isinstance(include_role_4, RoleNode)
assert include_role_4.include_role
assert len(include_role_4.tasks) == 0, "We don't support adding tasks from include_role with loop"
@pytest.mark.parametrize('grapher_cli', [["with_block.yml"]], indirect=True)
def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
"""
The parsing of a playbook with blocks
:param grapher_cli:
:param display:
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbook_filename, include_role_tasks=True)
@ -97,7 +156,10 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
# Check pre tasks
assert isinstance(pre_tasks[0].destination, RoleNode), "The first edge should have a RoleNode as destination"
assert isinstance(pre_tasks[1].destination, BlockNode), "The second edge should have a BlockNode as destination"
pre_task_block = pre_tasks[1].destination
assert isinstance(pre_task_block, BlockNode), "The second edge should have a BlockNode as destination"
assert pre_task_block.path == os.path.join(FIXTURES_PATH, "with_block.yml")
assert pre_task_block.line == 7
# Check tasks
task_1 = tasks[0].destination
@ -112,6 +174,7 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
# Check the second block (nested block)
nested_block = first_block.tasks[2].destination
assert isinstance(nested_block, BlockNode)
assert len(nested_block.tasks) == 2
assert nested_block.tasks[0].destination.name == "get_url"
assert nested_block.tasks[1].destination.name == "command"

View file

@ -9,6 +9,9 @@ from ansibleplaybookgrapher import __prog__
from ansibleplaybookgrapher.cli import get_cli_class
from tests import FIXTURES_DIR
# This file directory abspath
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
def run_grapher(playbook_file: str, output_filename: str = None, additional_args: List[str] = None) -> Tuple[str, str]:
"""
@ -23,7 +26,11 @@ def run_grapher(playbook_file: str, output_filename: str = None, additional_args
additional_args.insert(0, "-vv")
if os.environ.get("TEST_VIEW_GENERATED_FILE") == "1":
additional_args.insert(1, "--view")
additional_args.insert(0, "--view")
if "--open-protocol-handler" not in additional_args:
additional_args.insert(0, "--open-protocol-handler")
additional_args.insert(1, "vscode")
playbook_path = os.path.join(FIXTURES_DIR, playbook_file)
args = [__prog__]
@ -31,8 +38,7 @@ def run_grapher(playbook_file: str, output_filename: str = None, additional_args
if output_filename: # the default filename is the playbook file name minus .yml
# put the generated svg in a dedicated folder
output_filename = output_filename.replace("[", "-").replace("]", "")
dir_path = os.path.dirname(os.path.realpath(__file__)) # current file directory
args.extend(['-o', os.path.join(dir_path, "generated_svg", output_filename)])
args.extend(['-o', os.path.join(DIR_PATH, "generated_svg", output_filename)])
args.extend(additional_args)
@ -64,7 +70,7 @@ def _common_tests(svg_path: str, playbook_path: str, plays_number: int = 0, task
# test if the file exist. It will exist only if we write in it.
assert os.path.isfile(svg_path), "The svg file should exist"
assert pq('#root_node text').text() == playbook_path
assert pq('g[id^=playbook_] text').text() == playbook_path
plays = pq("g[id^='play_']")
tasks = pq("g[id^='task_']")
@ -133,7 +139,7 @@ def test_import_tasks(request):
@pytest.mark.parametrize(["include_role_tasks_option", "expected_tasks_number"],
[("--", 2), ("--include-role-tasks", 5)],
[("--", 2), ("--include-role-tasks", 8)],
ids=["no_include_role_tasks_option", "include_role_tasks_option"])
def test_with_roles(request, include_role_tasks_option, expected_tasks_number):
"""
@ -144,7 +150,7 @@ def test_with_roles(request, include_role_tasks_option, expected_tasks_number):
additional_args=[include_role_tasks_option])
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=expected_tasks_number,
post_tasks_number=2, pre_tasks_number=2, roles_number=1)
post_tasks_number=2, pre_tasks_number=2, roles_number=2)
@pytest.mark.parametrize(["include_role_tasks_option", "expected_tasks_number"],
@ -163,7 +169,7 @@ def test_include_role(request, include_role_tasks_option, expected_tasks_number)
def test_with_block(request):
"""
Test with_roles.yml, an example with roles
Test with_block.yml, an example with roles
"""
svg_path, playbook_path = run_grapher("with_block.yml", output_filename=request.node.name,
additional_args=["--include-role-tasks"])
@ -247,3 +253,24 @@ def test_skip_tags(request):
additional_args=["--skip-tags", "pre_task_tag_1", "--include-role-tasks"])
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, pre_tasks_number=1, roles_number=1,
tasks_number=3)
def test_with_roles_with_custom_protocol_handlers(request):
"""
Test with_roles.yml with a custom protocol handlers
"""
formats_str = '{"file": "vscode://file/{path}:{line}", "folder": "{path}"}'
svg_path, playbook_path = run_grapher("with_roles.yml", output_filename=request.node.name,
additional_args=["--open-protocol-handler", "custom",
"--open-protocol-custom-formats", formats_str])
res = _common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=2,
post_tasks_number=2, pre_tasks_number=2, roles_number=2)
xlink_ref_selector = "{http://www.w3.org/1999/xlink}href"
for t in res["tasks"]:
assert t.find("g/a").get(xlink_ref_selector).startswith(
f"vscode://file/{DIR_PATH}"), "Tasks should be open with vscode"
for r in res["roles"]:
assert r.find("g/a").get(xlink_ref_selector).startswith(DIR_PATH)