From d0027fe4eeec112c624f86158520d335cf10b429 Mon Sep 17 00:00:00 2001 From: Carmelo Mordini Date: Tue, 13 Jan 2026 10:10:25 +0100 Subject: [PATCH 1/5] Add file_dep and targets as edge labels --- doit_graph.py | 55 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/doit_graph.py b/doit_graph.py index bb61886..1493d8b 100644 --- a/doit_graph.py +++ b/doit_graph.py @@ -85,12 +85,51 @@ def node(self, 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) + 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 = existing_label + '\\n' + 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, pos_args=None): @@ -130,11 +169,15 @@ def _execute(self, subtasks, reverse, horizontal, outfile, pos_args=None): # add edges for sink_name in task.setup_tasks: - self.add_edge(task.name, sink_name, arrowhead='empty') + connecting_files = self.get_connecting_files(task.name, sink_name) + label = '\\n'.join(connecting_files) if connecting_files else 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='') + connecting_files = self.get_connecting_files(task.name, sink_name) + label = '\\n'.join(connecting_files) if connecting_files else None + self.add_edge(task.name, sink_name, arrowhead='', label=label) if sink_name not in processed: to_process.append(sink_name) From e95c1c0de7a439bbe407245da34b5b05925ea46b Mon Sep 17 00:00:00 2001 From: carmelom Date: Tue, 13 Jan 2026 09:27:55 +0000 Subject: [PATCH 2/5] Modernize installation --- pyproject.toml | 41 +++++++++++++++++++++++++++++++++++++++++ setup.py | 38 -------------------------------------- 2 files changed, 41 insertions(+), 38 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..94a0ac3 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 9faeea4..0000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -from setuptools import setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name = 'doit-graph', - description = "doit cmd plugin: create task's dependency-graph image", - version = '0.3.0', - license = 'MIT', - author = 'Eduardo Naufel Schettino', - url = 'http://github.com/pydoit/doit-graph', - long_description=long_description, - long_description_content_type="text/markdown", - - py_modules=['doit_graph'], - install_requires = ['doit', 'pygraphviz'], - entry_points = { - 'doit.COMMAND': [ - 'graph = doit_graph:GraphCmd' - ] - }, - - 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', - ), - keywords = "doit graph graphviz", -) From 2a8635b3014712876325ee0aac19393fd5753bf3 Mon Sep 17 00:00:00 2001 From: carmelom Date: Tue, 13 Jan 2026 09:29:47 +0000 Subject: [PATCH 3/5] Formatting --- doit_graph.py | 112 +++++++++++++++++++++++--------------------------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/doit_graph.py b/doit_graph.py index 1493d8b..52f4d43 100644 --- a/doit_graph.py +++ b/doit_graph.py @@ -10,51 +10,48 @@ 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_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). @@ -73,7 +70,6 @@ class GraphCmd(DoitCmdBase): cmd_options = (opt_subtasks, opt_outfile, opt_reverse, opt_horizontal) - def node(self, task_name): """get graph node that should represent for task_name @@ -84,7 +80,6 @@ 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, label=None): source = self.node(src_name) sink = self.node(sink_name) @@ -94,61 +89,59 @@ def add_edge(self, src_name, sink_name, arrowhead, label=None): if edge_key in self._edges: if label: existing_edge = self.graph.get_edge(source, sink) - existing_label = existing_edge.attr.get('label', '') + existing_label = existing_edge.attr.get("label", "") if existing_label: - new_label = existing_label + '\\n' + label + new_label = existing_label + "\\n" + label else: new_label = label - existing_edge.attr['label'] = new_label + existing_edge.attr["label"] = new_label else: self._edges.add(edge_key) - edge_attrs = {'arrowhead': arrowhead} + edge_attrs = {"arrowhead": arrowhead} if label: - edge_attrs['label'] = 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: + 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: + 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, 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: @@ -163,30 +156,29 @@ 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: connecting_files = self.get_connecting_files(task.name, sink_name) - label = '\\n'.join(connecting_files) if connecting_files else None - self.add_edge(task.name, sink_name, arrowhead='empty', label=label) + label = "\\n".join(connecting_files) if connecting_files else 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: connecting_files = self.get_connecting_files(task.name, sink_name) - label = '\\n'.join(connecting_files) if connecting_files else None - self.add_edge(task.name, sink_name, arrowhead='', label=label) + label = "\\n".join(connecting_files) if connecting_files else 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) - From e137ada3a113e84a05a4dc7c3132ddc8d0aa631b Mon Sep 17 00:00:00 2001 From: carmelom Date: Tue, 13 Jan 2026 09:36:03 +0000 Subject: [PATCH 4/5] Make labels optional --- doit_graph.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/doit_graph.py b/doit_graph.py index 52f4d43..43438ba 100644 --- a/doit_graph.py +++ b/doit_graph.py @@ -31,6 +31,15 @@ "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", @@ -68,7 +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 @@ -125,7 +134,7 @@ def get_connecting_files(self, src_task_name, sink_task_name): connecting_files = src_file_deps & sink_targets return sorted(connecting_files) - def _execute(self, subtasks, reverse, horizontal, outfile, pos_args=None): + def _execute(self, subtasks, reverse, horizontal, outfile, labels, pos_args=None): # init control = TaskControl(self.task_list) self.tasks = control.tasks @@ -162,14 +171,20 @@ def _execute(self, subtasks, reverse, horizontal, outfile, pos_args=None): # add edges for sink_name in task.setup_tasks: - connecting_files = self.get_connecting_files(task.name, sink_name) - label = "\\n".join(connecting_files) if connecting_files else None + 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: - connecting_files = self.get_connecting_files(task.name, sink_name) - label = "\\n".join(connecting_files) if connecting_files else None + 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) From 306971a5105831f8dd831f3a54ee0983257bdf8c Mon Sep 17 00:00:00 2001 From: Carmelo Mordini Date: Wed, 21 Jan 2026 16:58:40 +0100 Subject: [PATCH 5/5] Update edge label handling to avoid duplicates --- doit_graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doit_graph.py b/doit_graph.py index 43438ba..d6040c3 100644 --- a/doit_graph.py +++ b/doit_graph.py @@ -100,7 +100,8 @@ def add_edge(self, src_name, sink_name, arrowhead, label=None): existing_edge = self.graph.get_edge(source, sink) existing_label = existing_edge.attr.get("label", "") if existing_label: - new_label = existing_label + "\\n" + 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