Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 103 additions & 52 deletions doit_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,57 @@
from collections import deque

import pygraphviz

from doit.cmd_base import DoitCmdBase
from doit.control import TaskControl


opt_subtasks = {
'name': 'subtasks',
'short': '',
'long': 'show-subtasks',
'type': bool,
'default': False,
'help': 'include subtasks in graph',
"name": "subtasks",
"short": "",
"long": "show-subtasks",
"type": bool,
"default": False,
"help": "include subtasks in graph",
}

opt_reverse = {
'name': 'reverse',
'short': '',
'long': 'reverse',
'type': bool,
'default': False,
'help': 'draw edge in execution order, i.e. the reverse of dependency direction'
"name": "reverse",
"short": "",
"long": "reverse",
"type": bool,
"default": False,
"help": "draw edge in execution order, i.e. the reverse of dependency direction",
}

opt_labels = {
"name": "labels",
"short": "",
"long": "labels",
"type": bool,
"default": False,
"help": "label the edges with the file_dep/targets dependencies filenames",
}

opt_horizontal = {
'name': 'horizontal',
'short': 'h',
'long': 'horizontal',
'type': bool,
'default': False,
'help': 'draw graph in left-right mode, i.e. add rankdir=LR to digraph output'
"name": "horizontal",
"short": "h",
"long": "horizontal",
"type": bool,
"default": False,
"help": "draw graph in left-right mode, i.e. add rankdir=LR to digraph output",
}

opt_outfile = {
'name': 'outfile',
'short': 'o',
'long': 'output',
'type': str,
'default': None, # actually default dependends parameters
'help': 'name of generated dot-file',
"name": "outfile",
"short": "o",
"long": "output",
"type": str,
"default": None, # actually default dependends parameters
"help": "name of generated dot-file",
}



class GraphCmd(DoitCmdBase):
name = 'graph'
name = "graph"
doc_purpose = "create task's dependency-graph (in dot file format)"
doc_description = """Creates a DAG (directly acyclic graph) representation of tasks in graphviz's **dot** format (http://graphviz.org).

Expand All @@ -71,8 +77,7 @@ class GraphCmd(DoitCmdBase):
"""
doc_usage = "[TASK ...]"

cmd_options = (opt_subtasks, opt_outfile, opt_reverse, opt_horizontal)

cmd_options = (opt_subtasks, opt_outfile, opt_labels, opt_reverse, opt_horizontal)

def node(self, task_name):
"""get graph node that should represent for task_name
Expand All @@ -84,32 +89,69 @@ def node(self, task_name):
task = self.tasks[task_name]
return task.subtask_of or task_name


def add_edge(self, src_name, sink_name, arrowhead):
def add_edge(self, src_name, sink_name, arrowhead, label=None):
source = self.node(src_name)
sink = self.node(sink_name)
if source != sink and (source, sink) not in self._edges:
self._edges.add((source, sink))
self.graph.add_edge(source, sink, arrowhead=arrowhead)


def _execute(self, subtasks, reverse, horizontal, outfile, pos_args=None):
if source != sink:
edge_key = (source, sink)
# If edge already exists, append to label
if edge_key in self._edges:
if label:
existing_edge = self.graph.get_edge(source, sink)
existing_label = existing_edge.attr.get("label", "")
if existing_label:
new_label = set(existing_label.split("\\n")) | set(label.split("\\n"))
new_label = "\\n".join(sorted(new_label))
else:
new_label = label
existing_edge.attr["label"] = new_label
else:
self._edges.add(edge_key)
edge_attrs = {"arrowhead": arrowhead}
if label:
edge_attrs["label"] = label
self.graph.add_edge(source, sink, **edge_attrs)

def get_connecting_files(self, src_task_name, sink_task_name):
"""Find which files connect two tasks (file_dep of src that are in targets of sink)"""
from pathlib import Path

src_task = self.tasks.get(src_task_name)
sink_task = self.tasks.get(sink_task_name)

if not src_task or not sink_task:
return []

# Get file dependencies and targets
src_file_deps = set()
if hasattr(src_task, "file_dep") and src_task.file_dep:
src_file_deps = {Path(str(f)).name for f in src_task.file_dep}

sink_targets = set()
if hasattr(sink_task, "targets") and sink_task.targets:
sink_targets = {Path(str(t)).name for t in sink_task.targets}

# Find intersection
connecting_files = src_file_deps & sink_targets
return sorted(connecting_files)

def _execute(self, subtasks, reverse, horizontal, outfile, labels, pos_args=None):
# init
control = TaskControl(self.task_list)
self.tasks = control.tasks
self.subtasks = subtasks
self._edges = set() # used to avoid adding same edge twice
self._edges = set() # used to avoid adding same edge twice

# create graph
self.graph = pygraphviz.AGraph(strict=False, directed=True)
self.graph.node_attr['color'] = 'lightblue2'
self.graph.node_attr['style'] = 'filled'
self.graph.node_attr["color"] = "lightblue2"
self.graph.node_attr["style"] = "filled"

if (horizontal):
self.graph.graph_attr.update(rankdir='LR')
if horizontal:
self.graph.graph_attr.update(rankdir="LR")

# populate graph
processed = set() # str - task name
processed = set() # str - task name
if pos_args:
to_process = deque(pos_args)
else:
Expand All @@ -124,26 +166,35 @@ def _execute(self, subtasks, reverse, horizontal, outfile, pos_args=None):
# add nodes
node_attrs = {}
if task.has_subtask:
node_attrs['peripheries'] = '2'
node_attrs["peripheries"] = "2"
if (not task.subtask_of) or subtasks:
self.graph.add_node(task.name, **node_attrs)

# add edges
for sink_name in task.setup_tasks:
self.add_edge(task.name, sink_name, arrowhead='empty')
if labels:
connecting_files = self.get_connecting_files(task.name, sink_name)
label = "\\n".join(connecting_files) if connecting_files else None
else:
label = None
self.add_edge(task.name, sink_name, arrowhead="empty", label=label)
if sink_name not in processed:
to_process.append(sink_name)
for sink_name in task.task_dep:
self.add_edge(task.name, sink_name, arrowhead='')
if labels:
connecting_files = self.get_connecting_files(task.name, sink_name)
label = "\\n".join(connecting_files) if connecting_files else None
else:
label = None
self.add_edge(task.name, sink_name, arrowhead="", label=label)
if sink_name not in processed:
to_process.append(sink_name)

if not outfile:
name = pos_args[0] if len(pos_args)==1 else 'tasks'
outfile = '{}.dot'.format(name)
print('Generated file: {}'.format(outfile))
if (reverse):
name = pos_args[0] if len(pos_args) == 1 else "tasks"
outfile = "{}.dot".format(name)
print("Generated file: {}".format(outfile))
if reverse:
self.graph.reverse().write(outfile)
else:
self.graph.write(outfile)

41 changes: 41 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "doit-graph"
version = "0.3.0"
description = "doit cmd plugin: create task's dependency-graph image"
readme = "README.md"
license = {text = "MIT"}
authors = [
{name = "Eduardo Naufel Schettino"}
]
keywords = ["doit", "graph", "graphviz"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Intended Audience :: Developers",
]
requires-python = ">=3.5"
dependencies = [
"doit",
"pygraphviz",
]

[project.urls]
Homepage = "http://github.com/pydoit/doit-graph"

[project.entry-points."doit.COMMAND"]
graph = "doit_graph:GraphCmd"

[tool.setuptools]
py-modules = ["doit_graph"]
38 changes: 0 additions & 38 deletions setup.py

This file was deleted.