feat: Add support for a JSON renderer ()

Co-authored-by: haidaraM <haidaraM@users.noreply.github.com>
This commit is contained in:
Mohamed El Mouctar Haidara 2024-09-06 01:09:38 +02:00 committed by GitHub
parent 34e0aef74b
commit 90f5a30cb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1064 additions and 184 deletions

View file

@ -27,7 +27,7 @@ jobs:
- uses: psf/black@stable
with:
version: "~= 23.0" # https://black.readthedocs.io/en/stable/integrations/github_actions.html
version: "~= 24.0" # https://black.readthedocs.io/en/stable/integrations/github_actions.html
options: ""
- uses: stefanzweifel/git-auto-commit-action@v5

5
.gitignore vendored
View file

@ -108,6 +108,7 @@ venv.bak/
.idea
.vagrant
Vagrantfile
generated-svgs
generated-mermaids
tests/generated-svgs
tests/generated-mermaids/
tests/generated-jsons
**/.DS_Store

View file

@ -21,8 +21,12 @@ deploy_test: clean build
test_install: build
@./tests/test_install.sh $(VIRTUALENV_DIR) $(ANSIBLE_CORE_VERSION)
test:
cd tests && pytest test_cli.py test_utils.py test_parser.py test_graph_model.py test_graphviz_postprocessor.py test_graphviz_renderer.py test_mermaid_renderer.py
fmt:
black .
test: fmt
# Due to some side effects with Ansible, we have to run the tests in a certain order
cd tests && pytest test_cli.py test_utils.py test_parser.py test_graph_model.py test_graphviz_postprocessor.py test_graphviz_renderer.py test_mermaid_renderer.py test_json_renderer.py
clean:
@echo "Cleaning..."

215
README.md
View file

@ -5,19 +5,26 @@
[![Coverage Status](https://coveralls.io/repos/github/haidaraM/ansible-playbook-grapher/badge.svg?branch=main)](https://coveralls.io/github/haidaraM/ansible-playbook-grapher?branch=main)
[ansible-playbook-grapher](https://github.com/haidaraM/ansible-playbook-grapher) is a command line tool to create a
graph representing your Ansible playbook plays, tasks and roles. The aim of this project is to have an overview of your
playbook.
graph representing your Ansible playbook, plays, tasks and roles. The aim of this project is to have an overview of your
playbooks that you can use as documentation.
Inspired by [Ansible Inventory Grapher](https://github.com/willthames/ansible-inventory-grapher).
## Features
- Multiple [rendering formats](#renderers): graphviz, mermaid and JSON.
- Automatically find all your installed roles and collection.
- Native support of Ansible filters based on tags.
- Variables interpolation (when possible).
- Support `import_*` and `include_*`.
- Multiple flags to hide empty plays, group roles by name etc...
The following features are available when opening the SVGs in a browser (recommended) or a viewer that supports
JavaScript:
- Highlighting of all the related nodes of a given node when clicking or hovering. Example: Click on a role to select
all its tasks when `--include-role-tasks` is set.
- A double click on a node opens the corresponding file or folder depending whether if it's a playbook, a play, a task
- A double click on a node opens the corresponding file or folder depending on whether it's a playbook, a play, a task
or a role. By default, the browser will open folders and download files since it may not be able to render the YAML
file.
Optionally, you can
@ -26,8 +33,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)
- Filer tasks based on tags
and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/34e0aef74b82808dceb6ccfbeb333c0b531eac12/ansibleplaybookgrapher/renderer/__init__.py#L32-L41)
- Export the dot file used to generate the graph with Graphviz.
## Prerequisites
@ -38,7 +44,7 @@ JavaScript:
some versions of ansible-core which are not necessarily installed in your environment and may cause issues if you use
some older versions of Ansible (
since `ansible` [package has been split](https://www.ansible.com/blog/ansible-3.0.0-qa)).
- **Graphviz**: The tool used to generate the graph in SVG.
- **Graphviz**: The tool used to generate the graph in SVG. Optional if you don't plan to use the `graphviz` renderer.
```shell script
sudo apt-get install graphviz # or yum install or brew install
```
@ -52,7 +58,7 @@ for the supported Ansible version.
pip install ansible-playbook-grapher
```
You can also install the unpublished version from GitHub direction. Examples:
You can also install the unpublished version from GitHub direction. Examples:
```shell
# Install the version from the main branch
@ -66,11 +72,11 @@ pip install "ansible-playbook-grapher @ git+https://github.com/haidaraM/ansible-
At the time of writing, two renderers are supported:
1. `graphviz` (default): Generate the graph in SVG. It has more features and is more tested: open protocol,
highlight linked nodes...
1. `graphviz` (default): Generate the graph in SVG. Has more features than the other renderers.
2. `mermaid-flowchart`: Generate the graph in [Mermaid](https://mermaid.js.org/syntax/flowchart.html) format. You can
directly embed the graph in your markdown and GitHub (
and [other integrations](https://mermaid.js.org/ecosystem/integrations.html)) will render it. **Early support**.
and [other integrations](https://mermaid.js.org/ecosystem/integrations.html)) will render it.
3. `json`: Generate a JSON representation of the graph to be used by other tools.
If you are interested to support more renderers, feel free to create an issue or raise a PR based on the existing
renderers.
@ -83,19 +89,19 @@ ansible-playbook-grapher tests/fixtures/example.yml
![Example](https://raw.githubusercontent.com/haidaraM/ansible-playbook-grapher/main/img/example.png)
```bash
```shell
ansible-playbook-grapher --include-role-tasks tests/fixtures/with_roles.yml
```
![Example](https://raw.githubusercontent.com/haidaraM/ansible-playbook-grapher/main/img/with_roles.png)
```bash
```shell
ansible-playbook-grapher tests/fixtures/with_block.yml
```
![Example](https://raw.githubusercontent.com/haidaraM/ansible-playbook-grapher/main/img/block.png)
```bash
```shell
ansible-playbook-grapher --include-role-tasks --renderer mermaid-flowchart tests/fixtures/multi-plays.yml
```
@ -292,7 +298,83 @@ flowchart LR
%% End of the playbook 'tests/fixtures/multi-plays.yml'
```
Note on block: Since `block`s are logical group of tasks, the conditional `when` is not displayed on the edges pointing
```shell
ansible-playbook-grapher --renderer json tests/fixtures/simple_playbook.yml
```
```json
{
"version": 1,
"playbooks": [
{
"type": "PlaybookNode",
"id": "playbook_e4dc5cb3",
"name": "tests/fixtures/simple_playbook.yml",
"when": "",
"index": 1,
"location": {
"type": "file",
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
"line": 1,
"column": 1
},
"plays": [
{
"type": "PlayNode",
"id": "play_1c544613",
"name": "Play: all (0)",
"when": "",
"index": 1,
"location": {
"type": "file",
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
"line": 2,
"column": 3
},
"post_tasks": [
{
"type": "TaskNode",
"id": "post_task_a9b2e9ac",
"name": "Post task 1",
"when": "",
"index": 1,
"location": {
"type": "file",
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
"line": 4,
"column": 7
}
},
{
"type": "TaskNode",
"id": "post_task_61204621",
"name": "Post task 2",
"when": "",
"index": 2,
"location": {
"type": "file",
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
"line": 7,
"column": 7
}
}
],
"pre_tasks": [],
"roles": [],
"tasks": [],
"hosts": [],
"colors": {
"main": "#585874",
"font": "#ffffff"
}
}
]
}
]
}
```
Note on block: Since a `block` is a logical group of tasks, the conditional `when` is not displayed on the edges pointing
to them but on the tasks inside the block. This
mimics [Ansible behavior](https://docs.ansible.com/ansible/latest/user_guide/playbooks_blocks.html#grouping-tasks-with-blocks)
regarding the blocks.
@ -302,19 +384,11 @@ regarding the blocks.
The available options:
```
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]
[--group-roles-by-name]
[--renderer {graphviz,mermaid-flowchart}]
[--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE]
[--renderer-mermaid-orientation {TD,RL,BT,RL,LR}]
[--version] [-t TAGS] [--skip-tags SKIP_TAGS]
[--vault-id VAULT_IDS]
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
[-e EXTRA_VARS]
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] [--group-roles-by-name] [--renderer {graphviz,mermaid-flowchart,json}]
[--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE] [--renderer-mermaid-orientation {TD,RL,BT,RL,LR}] [--version]
[--hide-plays-without-roles] [--hide-empty-plays] [-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[-J | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
playbooks [playbooks ...]
Make graphs from your Ansible Playbooks.
@ -323,74 +397,51 @@ positional arguments:
playbooks Playbook(s) to graph
options:
--ask-vault-password, --ask-vault-pass
ask for vault password
--group-roles-by-name
When rendering the graph, only a single role will be
display for all roles having the same names. Default:
False
--include-role-tasks Include the tasks of the role in the graph.
When rendering the graph (graphviz and mermaid), only a single role will be displayed for all roles having the same names. Default: False
--hide-empty-plays Hide the plays that end up with no tasks in the graph (after applying the tags filter).
--hide-plays-without-roles
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).
--include-role-tasks Include the tasks of the roles in the graph. Applied when parsing the playbooks.
--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 it to: '{"file":
"vscode://file/{path}:{line}:{column}", "folder":
"{path}"}'. path: the absolute path to the file
containing the the plays/tasks/roles. line/column: the
position of the plays/tasks/roles in the file. You can
optionally add the attribute "remove_from_path" to
remove some parts of the path if you want relative
paths.
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 it to:
'{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'. path: the absolute path to the file containing the the plays/tasks/roles.
line/column: the position of the plays/tasks/roles in the file. You can optionally add the attribute "remove_from_path" to remove some parts of the
path if you want relative paths.
--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.
--renderer {graphviz,mermaid-flowchart}
The renderer to use to generate the graph. Default:
graphviz
The protocol to use to open the nodes when double-clicking on them in your SVG viewer (only for graphviz). 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.
--renderer {graphviz,mermaid-flowchart,json}
The renderer to use to generate the graph. Default: graphviz
--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE
The directive for the mermaid renderer. Can be used to
customize the output: fonts, theme, curve etc. More
info at https://mermaid.js.org/config/directives.html.
Default: '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
The directive for the mermaid renderer. Can be used to customize the output: fonts, theme, curve etc. More info at
https://mermaid.js.org/config/directives.html. Default: '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
--renderer-mermaid-orientation {TD,RL,BT,RL,LR}
The orientation of the flow chart. Default: 'LR'
--skip-tags SKIP_TAGS
only run plays and tasks whose tags do not match these
values
--vault-id VAULT_IDS the vault identity to use
only run plays and tasks whose tags do not match these values. This argument may be specified multiple times.
--vault-id VAULT_IDS the vault identity to use. This argument may be specified multiple times.
--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
--version
--view Automatically open the resulting SVG file with your systems default viewer application for the file type
-J, --ask-vault-password, --ask-vault-pass
ask for vault password
-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 @. This argument may be specified multiple times.
-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. This argument may be specified multiple times.
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
Output filename without the '.svg' extension. Default:
<playbook>.svg
Output filename without the '.svg' extension (for graphviz), '.mmd' for Mermaid or `.json`. The extension will be added automatically.
-s, --save-dot-file Save the graphviz dot file used to generate the graph.
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
-v, --verbose Causes Ansible to print more debug messages. Adding
multiple -v will increase the verbosity, the builtin
plugins currently evaluate up to -vvvvvv. A reasonable
level to start is -vvv, connection debugging might
require -vvvv.
-t TAGS, --tags TAGS only run plays and tasks tagged with these values. This argument may be specified multiple times.
-v, --verbose Causes Ansible to print more debug messages. Adding multiple -v will increase the verbosity, the builtin plugins currently evaluate up to -vvvvvv. A
reasonable level to start is -vvv, connection debugging might require -vvvv. This argument may be specified multiple times.
```
@ -435,7 +486,7 @@ make test # run all tests
```
The graphs are generated in the folder `tests/generated-svgs`. They are also generated as artefacts
in [Github Actions](https://github.com/haidaraM/ansible-playbook-grapher/actions). Feel free to look at them when
in [GitHub Actions](https://github.com/haidaraM/ansible-playbook-grapher/actions). Feel free to look at them when
submitting PRs.
### Lint and format

View file

@ -23,10 +23,11 @@ from ansible.errors import AnsibleOptionsError
from ansible.release import __version__ as ansible_version
from ansible.utils.display import Display
from ansibleplaybookgrapher.grapher import Grapher
from ansibleplaybookgrapher import __prog__, __version__
from ansibleplaybookgrapher.grapher import Grapher
from ansibleplaybookgrapher.renderer import OPEN_PROTOCOL_HANDLERS
from ansibleplaybookgrapher.renderer.graphviz import GraphvizRenderer
from ansibleplaybookgrapher.renderer.json import JSONRenderer
from ansibleplaybookgrapher.renderer.mermaid import (
MermaidFlowChartRenderer,
DEFAULT_DIRECTIVE as MERMAID_DEFAULT_DIRECTIVE,
@ -54,6 +55,7 @@ class PlaybookGrapherCLI(CLI):
self.options = None
def run(self):
# FIXME: run should not return anything.
super().run()
display.verbosity = self.options.verbosity
@ -65,38 +67,54 @@ class PlaybookGrapherCLI(CLI):
group_roles_by_name=self.options.group_roles_by_name,
)
if self.options.renderer == "graphviz":
renderer = GraphvizRenderer(
playbook_nodes=playbook_nodes,
roles_usage=roles_usage,
)
output_path = renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
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,
)
match self.options.renderer:
case "graphviz":
renderer = GraphvizRenderer(
playbook_nodes=playbook_nodes,
roles_usage=roles_usage,
)
return renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
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
else:
renderer = MermaidFlowChartRenderer(
playbook_nodes=playbook_nodes,
roles_usage=roles_usage,
)
output_path = renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
output_filename=self.options.output_filename,
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
case "mermaid-flowchart":
renderer = MermaidFlowChartRenderer(
playbook_nodes=playbook_nodes,
roles_usage=roles_usage,
)
return renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
output_filename=self.options.output_filename,
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,
)
case "json":
renderer = JSONRenderer(playbook_nodes, roles_usage)
return renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
output_filename=self.options.output_filename,
view=self.options.view,
hide_empty_plays=self.options.hide_empty_plays,
hide_plays_without_roles=self.options.hide_plays_without_roles,
)
case _:
# Likely a bug if we are here
raise AnsibleOptionsError(
f"Unknown renderer '{self.options.renderer}'. This is likely a bug that should be reported."
)
def _add_my_options(self):
"""
@ -141,7 +159,8 @@ class PlaybookGrapherCLI(CLI):
"-o",
"--output-file-name",
dest="output_filename",
help="Output filename without the '.svg' extension. Default: <playbook>.svg",
help="Output filename without the '.svg' extension (for graphviz), '.mmd' for Mermaid or `.json`. "
"The extension will be added automatically.",
)
self.parser.add_argument(
@ -150,7 +169,7 @@ class PlaybookGrapherCLI(CLI):
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.
viewer (only for graphviz). 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.
@ -180,12 +199,12 @@ class PlaybookGrapherCLI(CLI):
"--group-roles-by-name",
action="store_true",
default=False,
help="When rendering the graph, only a single role will be display for all roles having the same names. Default: %(default)s",
help="When rendering the graph (graphviz and mermaid), only a single role will be displayed for all roles having the same names. Default: %(default)s",
)
self.parser.add_argument(
"--renderer",
choices=["graphviz", "mermaid-flowchart"],
choices=["graphviz", "mermaid-flowchart", "json"],
default="graphviz",
help="The renderer to use to generate the graph. Default: %(default)s",
)

View file

@ -1,4 +1,4 @@
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
# Copyright (C) 2024 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -14,6 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from collections import defaultdict
from dataclasses import dataclass, asdict
from typing import Dict, List, Set, Tuple, Optional
from ansibleplaybookgrapher.utils import generate_id, get_play_colors
@ -35,6 +36,25 @@ class LoopMixin:
return self.raw_object.loop is not None
@dataclass
class NodeLocation:
"""
The node location on the filesystem.
The location can be a folder (for roles) or a specific line and column inside a file
"""
type: str # file or folder
path: Optional[str] = None
line: Optional[int] = None
column: Optional[int] = None
def __post_init__(self):
if self.type not in ["folder", "file"]:
raise ValueError(
f"Type '{self.type}' not supported. Valid values: file, folder."
)
class Node:
"""
A node in the graph. Everything of the final graph is a node: playbook, plays, tasks and roles.
@ -64,20 +84,23 @@ class Node:
self.when = when
self.raw_object = raw_object
# Get the node position in the parsed files. Format: (path,line,column)
self.path = self.line = self.column = None
self.set_position()
self.location: Optional[NodeLocation] = None
self.set_location()
# The index of this node in the parent node if it has one (starting from 1)
self.index: Optional[int] = index
def set_position(self):
def set_location(self):
"""
Set the path of this based on the raw object. Not all objects have path
Set the location of this node 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
path, line, column = self.raw_object.get_ds().ansible_pos
# By default, it's a file
self.location = NodeLocation(
type="file", path=path, line=line, column=column
)
def get_first_parent_matching_type(self, node_type: type) -> type:
"""
@ -106,6 +129,27 @@ class Node:
def __hash__(self):
return hash(self.id)
def to_dict(self, **kwargs) -> Dict:
"""
Return a dictionary representation of this node. This representation is not meant to get the original object
back.
:return:
"""
data = {
"type": type(self).__name__,
"id": self.id,
"name": self.name,
"when": self.when,
"index": self.index,
}
if self.location is not None:
data["location"] = asdict(self.location)
else:
data["location"] = None
return data
class CompositeNode(Node):
"""
@ -143,7 +187,7 @@ class CompositeNode(Node):
index=index,
)
self._supported_compositions = supported_compositions or []
# The dict will contain the different types of composition.
# The dict will contain the different types of composition: plays, tasks, roles...
self._compositions = defaultdict(list) # type: Dict[str, List]
# Used to count the number of nodes in this composite node
self._node_counter = 0
@ -172,7 +216,7 @@ class CompositeNode(Node):
"""
if target_composition not in self._supported_compositions:
raise Exception(
f"The target composition '{target_composition}' is unknown. Supported are: {self._supported_compositions}"
f"The target composition '{target_composition}' is unknown. Supported ones are: {self._supported_compositions}"
)
return self._compositions[target_composition]
@ -248,6 +292,23 @@ class CompositeNode(Node):
return False
def to_dict(self, **kwargs) -> Dict:
"""
Return a dictionary representation of this composite node. This representation is not meant to get the
original object back.
:return:
"""
node_dict = super().to_dict(**kwargs)
for composition, nodes in self._compositions.items():
nodes_dict_list = []
for node in nodes:
nodes_dict_list.append(node.to_dict(**kwargs))
node_dict[composition] = nodes_dict_list
return node_dict
class CompositeTasksNode(CompositeNode):
"""
@ -313,15 +374,15 @@ class PlaybookNode(CompositeNode):
supported_compositions=["plays"],
)
def set_position(self):
def set_location(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
self.location = NodeLocation(
type="file", path=os.path.join(os.getcwd(), self.name), line=1, column=1
)
def plays(
self, exclude_empty: bool = False, exclude_without_roles: bool = False
@ -363,6 +424,31 @@ class PlaybookNode(CompositeNode):
return usages
def to_dict(
self,
exclude_empty_plays: bool = False,
exclude_plays_without_roles: bool = False,
**kwargs,
) -> Dict:
"""
Return a dictionary representation of this playbook
:param exclude_empty_plays: Whether to exclude the empty plays from the result or not
:param exclude_plays_without_roles: Whether to exclude the plays that do not have roles
:param kwargs:
:return:
"""
playbook_dict = super().to_dict(**kwargs)
playbook_dict["plays"] = []
# We need to explicitly get the plays here to exclude the ones we don't need
for play in self.plays(
exclude_empty=exclude_empty_plays,
exclude_without_roles=exclude_plays_without_roles,
):
playbook_dict["plays"].append(play.to_dict(**kwargs))
return playbook_dict
class PlayNode(CompositeNode):
"""
@ -420,6 +506,18 @@ class PlayNode(CompositeNode):
def tasks(self) -> List["Node"]:
return self.get_nodes("tasks")
def to_dict(self, **kwargs) -> Dict:
"""
Return a dictionary representation of this composite node. This representation is not meant to get the
original object back.
:return:
"""
data = super().to_dict(**kwargs)
data["hosts"] = self.hosts
data["colors"] = {"main": self.colors[0], "font": self.colors[1]}
return data
class BlockNode(CompositeTasksNode):
"""
@ -506,17 +604,18 @@ class RoleNode(LoopMixin, CompositeTasksNode):
index=index,
)
def set_position(self):
def set_location(self):
"""
Retrieve the position depending on whether it's an include_role or not
:return:
"""
if self.raw_object and not self.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 = self.raw_object._role_path
# If it's not an include_role, we take the role path which is the path to the folder where the role
# is located on the disk.
self.location = NodeLocation(type="folder", path=self.raw_object._role_path)
else:
super().set_position()
super().set_location()
def has_loop(self) -> bool:
if not self.include_role:
@ -524,3 +623,15 @@ class RoleNode(LoopMixin, CompositeTasksNode):
return False
return super().has_loop()
def to_dict(self, **kwargs) -> Dict:
"""
Return a dictionary representation of this composite node. This representation is not meant to get the
original object back.
:param kwargs:
:return:
"""
node_dict = super().to_dict(**kwargs)
node_dict["include_role"] = self.include_role
return node_dict

View file

@ -50,7 +50,7 @@ class Grapher:
playbook_nodes = []
roles_usage: Dict[RoleNode, Set[PlayNode]] = {}
for playbook_file in self.playbook_filenames:
for counter, playbook_file in enumerate(self.playbook_filenames, 1):
playbook_parser = PlaybookParser(
playbook_filename=playbook_file,
tags=tags,
@ -59,6 +59,7 @@ class Grapher:
group_roles_by_name=group_roles_by_name,
)
playbook_node = playbook_parser.parse()
playbook_node.index = counter
playbook_nodes.append(playbook_node)
# Update the usage of the roles

View file

@ -167,7 +167,7 @@ class PlaybookParser(BaseParser):
add post_tasks
:return:
"""
display.display(f"Parsing playbook {self.playbook_filename}")
display.display(f"Parsing the playbook '{self.playbook_filename}'")
playbook = Playbook.load(
self.playbook_filename,
loader=self.data_loader,

View file

@ -1,4 +1,4 @@
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
# Copyright (C) 2024 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -122,7 +122,7 @@ class PlaybookBuilder(ABC):
)
elif isinstance(node, RoleNode):
self.build_role(role_node=node, color=color, fontcolor=fontcolor, **kwargs)
else: # This is necessarily a TaskNode
elif isinstance(node, TaskNode):
self.build_task(
task_node=node,
color=color,
@ -130,6 +130,10 @@ class PlaybookBuilder(ABC):
node_label_prefix=kwargs.pop("node_label_prefix", ""),
**kwargs,
)
else:
raise Exception(
f"Unsupported node type: {type(node)}. This is likely a bug that should be reported"
)
@abstractmethod
def build_playbook(
@ -239,19 +243,19 @@ class PlaybookBuilder(ABC):
"""
pass
def get_node_url(self, node: Node, node_type: str) -> Optional[str]:
def get_node_url(self, node: Node) -> Optional[str]:
"""
Get the node url based on the chosen protocol
:param node_type: task or role
Get the node url based on the chosen open protocol.
:param node: the node to get the url for
:return:
"""
if node.path:
if node.location and node.location.path:
remove_from_path = self.open_protocol_formats.get("remove_from_path", "")
path = node.path.replace(remove_from_path, "")
path = node.location.path.replace(remove_from_path, "")
url = self.open_protocol_formats[node_type].format(
path=path, line=node.line, column=node.column
url = self.open_protocol_formats[node.location.type].format(
path=path, line=node.location.line, column=node.location.column
)
display.vvvv(f"Open protocol URL for node {node}: {url}")
return url

View file

@ -162,7 +162,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
id=task_node.id,
tooltip=task_node.name,
color=color,
URL=self.get_node_url(task_node, "file"),
URL=self.get_node_url(task_node),
)
# Edge from parent to task
@ -212,7 +212,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
color=color,
fontcolor=fontcolor,
labeltooltip=block_node.name,
URL=self.get_node_url(block_node, "file"),
URL=self.get_node_url(block_node),
)
# The reverse here is a little hack due to how graphviz render nodes inside a cluster by reversing them.
@ -253,9 +253,9 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
self.roles_built.add(role_node)
if role_node.include_role: # For include_role, we point to a file
url = self.get_node_url(role_node, "file")
url = self.get_node_url(role_node)
else: # For normal role invocation, we point to the folder
url = self.get_node_url(role_node, "folder")
url = self.get_node_url(role_node)
plays_using_this_role = self.roles_usage[role_node]
if len(plays_using_this_role) > 1:
@ -301,7 +301,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
label=self.playbook_node.name,
style="dotted",
id=self.playbook_node.id,
URL=self.get_node_url(self.playbook_node, "file"),
URL=self.get_node_url(self.playbook_node),
)
for play in self.playbook_node.plays(
@ -337,7 +337,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
color=color,
fontcolor=play_font_color,
tooltip=play_tooltip,
URL=self.get_node_url(play_node, "file"),
URL=self.get_node_url(play_node),
)
# from playbook to play

View file

@ -0,0 +1,156 @@
# Copyright (C) 2024 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# 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 os
import subprocess
import sys
from pathlib import Path
from typing import Dict, Optional
from ansible.utils.display import Display
from ansibleplaybookgrapher.graph_model import (
BlockNode,
RoleNode,
TaskNode,
PlayNode,
PlaybookNode,
)
from ansibleplaybookgrapher.renderer import PlaybookBuilder, Renderer
display = Display()
class JSONRenderer(Renderer):
"""
A renderer that writes the graph to a JSON file
"""
def render(
self,
open_protocol_handler: Optional[str],
open_protocol_custom_formats: Optional[Dict[str, str]],
output_filename: str,
view: bool,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
**kwargs,
) -> str:
playbooks = []
for playbook_node in self.playbook_nodes:
json_builder = JSONPlaybookBuilder(playbook_node, open_protocol_handler)
json_builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
)
playbooks.append(json_builder.json_output)
output = {
"version": 1,
"playbooks": playbooks,
}
final_output_path_file = Path(f"{output_filename}.json")
# Make the sure the parents directories exist
final_output_path_file.parent.mkdir(exist_ok=True, parents=True)
dump_str = json.dumps(output, indent=2)
final_output_path_file.write_text(dump_str)
display.display(f"JSON file written to {final_output_path_file}", color="green")
if view:
if sys.platform == "win32":
os.startfile(str(final_output_path_file))
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.call([opener, str(final_output_path_file)])
return str(final_output_path_file)
class JSONPlaybookBuilder(PlaybookBuilder):
def __init__(self, playbook_node: PlaybookNode, open_protocol_handler: str):
super().__init__(playbook_node, open_protocol_handler)
self.json_output = {}
def build_playbook(
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
**kwargs,
) -> str:
"""
:param hide_empty_plays:
:param hide_plays_without_roles:
:param kwargs:
:return:
"""
display.vvv(
f"Converting the playbook '{self.playbook_node.name}' to JSON format"
)
self.json_output = self.playbook_node.to_dict(
exclude_empty_plays=hide_empty_plays,
exclude_plays_without_roles=hide_plays_without_roles,
)
return json.dumps(self.json_output)
def build_play(self, play_node: PlayNode, **kwargs):
"""
Not needed
:param play_node:
:param kwargs:
:return:
"""
pass
def build_task(self, task_node: TaskNode, color: str, fontcolor: str, **kwargs):
"""
Not needed
:param task_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
pass
def build_role(self, role_node: RoleNode, color: str, fontcolor: str, **kwargs):
"""
Not needed
:param role_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
pass
def build_block(self, block_node: BlockNode, color: str, fontcolor: str, **kwargs):
"""
Not needed
:param block_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
pass

View file

@ -1,4 +1,4 @@
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
# Copyright (C) 2024 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -40,13 +40,6 @@ DEFAULT_ORIENTATION = "LR" # Left to right
class MermaidFlowChartRenderer(Renderer):
def __init__(
self,
playbook_nodes: List[PlaybookNode],
roles_usage: Dict["RoleNode", Set[PlayNode]],
):
super().__init__(playbook_nodes, roles_usage)
def render(
self,
open_protocol_handler: str,

226
tests/fixtures/json-schemas/v1.json vendored Normal file
View file

@ -0,0 +1,226 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Ansible Playbook Grapher JSON Output schema (v1)",
"description": "The schema definition of the version 1 of the json renderer",
"type": "object",
"properties": {
"version": {
"description": "The version of the JSON output",
"type": "integer"
},
"playbooks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"description": "Type of the node.",
"type": "string",
"pattern": "^PlaybookNode$"
},
"id": {
"pattern": "^playbook_.+$",
"type": "string"
},
"name": {
"type": "string"
},
"when": {
"type": "string"
},
"index": {
"type": "integer"
},
"location": {
"$ref": "#/$defs/location"
},
"plays": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"pattern": "^PlayNode$",
"type": "string"
},
"id": {
"pattern": "^play_.+$",
"type": "string"
},
"name": {
"type": "string"
},
"when": {
"type": "string"
},
"index": {
"type": "integer"
},
"colors": {
"type": "object",
"properties": {
"main": {
"type": "string"
},
"font": {
"type": "string"
}
},
"required": [
"main",
"font"
]
},
"hosts": {
"type": "array",
"items": {
"type": "string"
}
},
"location": {
"$ref": "#/$defs/location"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
},
"pre_tasks": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
},
"post_tasks": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
}
},
"required": [
"type",
"id",
"name",
"when",
"index",
"location",
"colors",
"hosts",
"roles",
"tasks",
"pre_tasks",
"post_tasks"
]
}
}
},
"required": [
"type",
"id",
"name",
"when",
"index",
"location",
"plays"
]
}
}
},
"required": [
"version",
"playbooks"
],
"$defs": {
"task": {
"description": "An Ansible task or role",
"type": "object",
"properties": {
"type": {
"pattern": "^(TaskNode|BlockNode|RoleNode)$",
"type": "string"
},
"id": {
"pattern": "^(pre_task|task|post_task|role|block)_.+$",
"type": "string"
},
"name": {
"description": "The name of the task or role",
"type": "string"
},
"when": {
"description": "The condition on the task",
"type": "string"
},
"index": {
"description": "The index of the task",
"type": "integer"
},
"location": {
"$ref": "#/$defs/location"
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
},
"include_role": {
"description": "Flag indicating if this role is an include role or not.",
"type": "boolean"
}
},
"required": [
"type",
"id",
"name",
"when",
"index",
"location"
]
},
"location": {
"description": "The node location on the filesystem.",
"type": "object",
"properties": {
"type": {
"description": "The type of the location",
"type": "string",
"enum": [
"file",
"folder"
]
},
"path": {
"description": "Filesystem path",
"type": "string"
},
"line": {
"type": [
"integer",
"null"
]
},
"column": {
"type": [
"integer",
"null"
]
}
},
"required": [
"type",
"path",
"line",
"column"
]
}
}
}

View file

@ -2,4 +2,6 @@
pytest==8.3.2
pytest-cov==5.0.0
pyquery==2.0.1
black~=24.8
black~=24.8
jq==1.8.0
jsonschema[format]==4.23.0

View file

@ -1,8 +1,11 @@
import json
from ansibleplaybookgrapher.graph_model import (
RoleNode,
TaskNode,
PlayNode,
BlockNode,
PlaybookNode,
)
@ -99,3 +102,41 @@ def test_has_node_type():
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"
def test_to_dict():
"""
:return:
"""
playbook = PlaybookNode("my-fake-playbook.yml")
playbook.add_node("plays", PlayNode("empty"))
role = RoleNode("my_role")
role.add_node("tasks", TaskNode("task 1"))
block = BlockNode("block 1")
block.add_node("tasks", role)
play = PlayNode("play")
play.add_node("tasks", block)
play.add_node("post_tasks", TaskNode("task 2"))
playbook.add_node("plays", play)
dict_rep = playbook.to_dict(exclude_empty_plays=True)
assert dict_rep["type"] == "PlaybookNode"
assert dict_rep["location"]["path"] is not None
assert dict_rep["location"]["line"] is not None
assert dict_rep["location"]["column"] is not None
assert len(dict_rep["plays"]) == 1
assert dict_rep["plays"][0]["type"] == "PlayNode"
assert dict_rep["plays"][0]["colors"]["font"] == "#ffffff"
assert dict_rep["plays"][0]["name"] == "play"
assert dict_rep["plays"][0]["tasks"][0]["name"] == "block 1"
assert dict_rep["plays"][0]["tasks"][0]["index"] == 1
assert dict_rep["plays"][0]["tasks"][0]["type"] == "BlockNode"
print(json.dumps(dict_rep, indent=4))

View file

@ -61,9 +61,7 @@ def run_grapher(
# put the generated file in a dedicated folder
args.extend(["-o", os.path.join(DIR_PATH, "generated-svgs", output_filename)])
args.extend(additional_args)
args.extend(playbook_paths)
args.extend(additional_args + playbook_paths)
cli = PlaybookGrapherCLI(args)
@ -115,7 +113,7 @@ def _common_tests(
assert (
len(playbooks) == playbooks_number
), f"The graph '{svg_path}' should contains {playbooks_number} play(s) but we found {len(playbooks)} play(s)"
), f"The graph '{svg_path}' should contains {playbooks_number} playbook(s) but we found {len(playbooks)} play(s)"
assert (
len(plays) == plays_number
@ -147,6 +145,7 @@ def _common_tests(
"post_tasks": post_tasks,
"pre_tasks": pre_tasks,
"roles": roles,
"blocks": blocks,
}
@ -443,7 +442,7 @@ def test_multi_plays(request):
)
def test_multiple_playbooks(request):
def test_multi_playbooks(request):
"""
Test with multiple playbooks
"""

268
tests/test_json_renderer.py Normal file
View file

@ -0,0 +1,268 @@
import json
import os
from typing import List, Tuple, Dict
import jq
import pytest
from jsonschema import validate
from jsonschema.validators import Draft202012Validator
from ansibleplaybookgrapher import __prog__
from ansibleplaybookgrapher.cli import PlaybookGrapherCLI
from tests import FIXTURES_DIR
# This file directory abspath
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
def run_grapher(
playbook_files: List[str],
output_filename: str = None,
additional_args: List[str] = None,
) -> Tuple[str, List[str]]:
"""
Utility function to run the grapher
:param playbook_files:
:param output_filename:
:param additional_args:
:return:
"""
additional_args = additional_args or []
# Explicitly add verbosity to the tests
additional_args.insert(0, "-vvv")
if os.environ.get("TEST_VIEW_GENERATED_FILE") == "1":
additional_args.insert(0, "--view")
playbook_paths = [os.path.join(FIXTURES_DIR, p_file) for p_file in playbook_files]
args = [__prog__]
# Clean the name a little bit
output_filename = output_filename.replace("[", "-").replace("]", "")
# put the generated file in a dedicated folder
args.extend(["-o", os.path.join(DIR_PATH, "generated-jsons", output_filename)])
args.extend(["--renderer", "json"])
args.extend(additional_args + playbook_paths)
cli = PlaybookGrapherCLI(args)
return cli.run(), playbook_paths
def _common_tests(
json_path: str,
playbooks_number: int = 1,
plays_number: int = 0,
tasks_number: int = 0,
post_tasks_number: int = 0,
roles_number: int = 0,
pre_tasks_number: int = 0,
blocks_number: int = 0,
) -> Dict:
"""
Do some checks on the generated json files.
We are using JQ to avoid traversing the JSON ourselves (much easier).
:param json_path:
:return:
"""
with open(json_path, "r") as f:
output = json.load(f)
with open(os.path.join(FIXTURES_DIR, "json-schemas/v1.json")) as schema_file:
schema = json.load(schema_file)
# If no exception is raised by validate(), the instance is valid.
# I currently don't use format but added it here to not forget to add in case I use in the future.
validate(
instance=output,
schema=schema,
format_checker=Draft202012Validator.FORMAT_CHECKER,
)
playbooks = jq.compile(".playbooks[]").input(output).all()
plays = (
jq.compile(
'.. | objects | select(.type == "PlayNode" and (.id | startswith("play_")))'
)
.input(output)
.all()
)
pre_tasks = (
jq.compile(
'.. | objects | select(.type == "TaskNode" and (.id | startswith("pre_task_")))'
)
.input(output)
.all()
)
tasks = (
jq.compile(
'.. | objects | select(.type == "TaskNode" and (.id | startswith("task_")))'
)
.input(output)
.all()
)
post_tasks = (
jq.compile(
'.. | objects | select(.type == "TaskNode" and (.id | startswith("post_task_")))'
)
.input(output)
.all()
)
roles = (
jq.compile(
'.. | objects | select(.type == "RoleNode" and (.id | startswith("role_")))'
)
.input(output)
.all()
)
blocks = (
jq.compile(
'.. | objects | select(.type == "BlockNode" and (.id | startswith("block_")))'
)
.input(output)
.all()
)
assert (
len(playbooks) == playbooks_number
), f"The file '{json_path}' should contains {playbooks_number} playbook(s) but we found {len(playbooks)} playbook(s)"
assert (
len(plays) == plays_number
), f"The file '{json_path}' should contains {plays_number} play(s) but we found {len(plays)} play(s)"
assert (
len(pre_tasks) == pre_tasks_number
), f"The file '{json_path}' should contains {pre_tasks_number} pre tasks(s) but we found {len(pre_tasks)} pre tasks"
assert (
len(roles) == roles_number
), f"The file '{json_path}' should contains {roles_number} role(s) but we found {len(roles)} role(s)"
assert (
len(tasks) == tasks_number
), f"The file '{json_path}' should contains {tasks_number} tasks(s) but we found {len(tasks)} tasks"
assert (
len(post_tasks) == post_tasks_number
), f"The file '{json_path}' should contains {post_tasks_number} post tasks(s) but we found {len(post_tasks)} post tasks"
assert (
len(blocks) == blocks_number
), f"The file '{json_path}' should contains {blocks_number} block(s) but we found {len(blocks)} blocks"
# Check the play
for play in plays:
assert (
play.get("colors") is not None
), f"The play '{play['name']}' is missing colors'"
return {
"tasks": tasks,
"plays": plays,
"post_tasks": post_tasks,
"pre_tasks": pre_tasks,
"roles": roles,
"blocks": blocks,
}
def test_simple_playbook(request):
"""
:return:
"""
json_path, playbook_paths = run_grapher(
["simple_playbook.yml"],
output_filename=request.node.name,
additional_args=[
"-i",
os.path.join(FIXTURES_DIR, "inventory"),
"--include-role-tasks",
],
)
_common_tests(json_path, plays_number=1, post_tasks_number=2)
def test_with_block(request):
"""
:return:
"""
json_path, playbook_paths = run_grapher(
["with_block.yml"],
output_filename=request.node.name,
additional_args=[
"-i",
os.path.join(FIXTURES_DIR, "inventory"),
"--include-role-tasks",
],
)
_common_tests(
json_path,
plays_number=1,
pre_tasks_number=4,
roles_number=1,
tasks_number=7,
blocks_number=4,
post_tasks_number=2,
)
@pytest.mark.parametrize(
["flag", "roles_number", "tasks_number", "post_tasks_number"],
[("--", 6, 9, 8), ("--group-roles-by-name", 6, 9, 8)],
ids=["no_group", "group"],
)
def test_group_roles_by_name(
request, flag, roles_number, tasks_number, post_tasks_number
):
"""
Test when grouping roles by name. This doesn't really affect the JSON renderer: multiple nodes will have the same ID.
This test ensures that regardless of the flag '--group-roles-by-name', we get the same nodes in the output.
:param request:
:return:
"""
json_path, playbook_paths = run_grapher(
["group-roles-by-name.yml"],
output_filename=request.node.name,
additional_args=["--include-role-tasks", flag],
)
_common_tests(
json_path,
plays_number=1,
roles_number=roles_number,
tasks_number=tasks_number,
post_tasks_number=post_tasks_number,
blocks_number=1,
)
def test_multi_playbooks(request):
"""
:param request:
:return:
"""
json_path, playbook_paths = run_grapher(
["multi-plays.yml", "relative_var_files.yml", "with_roles.yml"],
output_filename=request.node.name,
additional_args=["--include-role-tasks"],
)
_common_tests(
json_path,
playbooks_number=3,
plays_number=5,
pre_tasks_number=4,
roles_number=10,
tasks_number=35,
post_tasks_number=4,
)

View file

@ -49,16 +49,16 @@ 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 playbook_node.path == os.path.join(FIXTURES_PATH, "example.yml")
assert playbook_node.line == 1
assert playbook_node.column == 1
assert playbook_node.location.path == os.path.join(FIXTURES_PATH, "example.yml")
assert playbook_node.location.line == 1
assert playbook_node.location.column == 1
assert (
playbook_node.index is None
), "The index of the playbook should be None (it has no parent)"
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.location.path == os.path.join(FIXTURES_PATH, "example.yml")
assert play_node.location.line == 2
assert play_node.index == 1
pre_tasks = play_node.pre_tasks
@ -99,9 +99,9 @@ def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI):
fake_role = play_node.roles[0]
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
assert fake_role.location.path == os.path.join(FIXTURES_PATH, "roles", "fake_role")
assert fake_role.location.line is None
assert fake_role.location.column is None
assert fake_role.index == 3
for task_counter, task in enumerate(fake_role.tasks):
@ -144,8 +144,12 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys):
include_role_1 = block_include_role.tasks[0]
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 == 10, "The first include role should be at line 9"
assert include_role_1.location.path == os.path.join(
FIXTURES_PATH, "include_role.yml"
)
assert (
include_role_1.location.line == 10
), "The first include role should be at line 9"
assert (
len(include_role_1.tasks) == 0
), "We don't support adding tasks from include_role with loop"
@ -223,8 +227,8 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
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
assert pre_task_block.location.path == os.path.join(FIXTURES_PATH, "with_block.yml")
assert pre_task_block.location.line == 7
# Check tasks
task_1 = tasks[0]