diff --git a/dvc/cli.py b/dvc/cli.py index 4578fdb14..4291202b8 100644 --- a/dvc/cli.py +++ b/dvc/cli.py @@ -755,16 +755,28 @@ def parse_args(argv=None): default=False, help='Print output files instead of ' 'paths to DVC files.') + pipeline_show_parser.add_argument( + '--ascii', + action='store_true', + default=False, + help='Output DAG as ASCII.') pipeline_show_parser.add_argument( 'targets', - nargs='+', - help='DVC files.') + nargs='*', + help="DVC files. 'Dvcfile' by default.") pipeline_show_parser.set_defaults(func=CmdPipelineShow) args = parser.parse_args(argv) - if args.cmd == 'repro' and len(args.targets) == 0: - path = os.path.join(args.cwd, 'Dvcfile') + if (issubclass(args.func, CmdRepro) + or issubclass(args.func, CmdPipelineShow)) \ + and hasattr(args, 'targets') \ + and len(args.targets) == 0: + if hasattr(args, 'cwd'): + cwd = args.cwd + else: + cwd = os.curdir + path = os.path.join(cwd, 'Dvcfile') if not os.path.exists(path): msg = "error: default target '{}' does not exist.\n" sys.stderr.write(msg.format(path)) diff --git a/dvc/command/pipeline.py b/dvc/command/pipeline.py index 299da9fe1..3ccb50487 100644 --- a/dvc/command/pipeline.py +++ b/dvc/command/pipeline.py @@ -2,6 +2,7 @@ import os from dvc.exceptions import DvcException from dvc.command.base import CmdBase +from dvc.dagascii import Dagascii class CmdPipelineShow(CmdBase): @@ -23,10 +24,59 @@ class CmdPipelineShow(CmdBase): else: self.project.logger.info(n) + def _show_ascii(self, target, commands, outs): + import networkx + from dvc.stage import Stage + + stage = Stage.load(self.project, target) + node = os.path.relpath(stage.path, self.project.root_dir) + + pipelines = list(filter(lambda g: node in g.nodes(), + self.project.pipelines())) + if len(pipelines) == 0: + return + + assert len(pipelines) == 1 + G = pipelines[0] + stages = networkx.get_node_attributes(G, 'stage') + + nodes = set() + edges = [] + for e in G.edges(): + from_stage = stages[e[0]] + to_stage = stages[e[1]] + if commands: + if to_stage.cmd is None: + continue + nodes.add(from_stage.cmd) + nodes.add(to_stage.cmd) + edges.append((from_stage.cmd, to_stage.cmd)) + elif outs: + for from_out in from_stage.outs: + for to_out in to_stage.outs: + nodes.add(from_out.rel_path) + nodes.add(to_out.rel_path) + edges.append((from_out.rel_path, + to_out.rel_path)) + else: + nodes.add(from_stage.relpath) + nodes.add(to_stage.relpath) + edges.append((from_stage.relpath, to_stage.relpath)) + + d = Dagascii(nodes, edges) + d.draw() + def run(self, unlock=False): for target in self.args.targets: try: - self._show(target, self.args.commands, self.args.outs) + if self.args.ascii: + self._show_ascii(target, + self.args.commands, + self.args.outs) + else: + self._show(target, + self.args.commands, + self.args.outs) except DvcException as ex: msg = 'Failed to show pipeline for \'{}\''.format(target) self.project.logger.error(msg, ex) diff --git a/dvc/dagascii.py b/dvc/dagascii.py new file mode 100644 index 000000000..b1bd35765 --- /dev/null +++ b/dvc/dagascii.py @@ -0,0 +1,121 @@ +import math + +from asciicanvas.asciicanvas import AsciiCanvas +from asciicanvas.style import Style + +from grandalf.graphs import Vertex, Edge, Graph +from grandalf.layouts import SugiyamaLayout +from grandalf.routing import route_with_lines, EdgeViewer + + +class Dagascii(object): + def __init__(self, vertexes, edges): + # + # Just a reminder about naming conventions: + # +------------X + # | + # | + # | + # | + # Y + # + + V = {v: Vertex(" {} ".format(v)) for v in vertexes} + # NOTE: reverting edges to correctly orientate the graph + E = [Edge(V[e], V[s]) for s, e in edges] + V = V.values() + g = Graph(V, E) + + class VertexViewer(object): + h = 3 # top and bottom box edges + text + + def __init__(self, name): + self.w = len(name) + 2 # right and left bottom edges + text + + for v in V: + v.view = VertexViewer(v.data) + + # NOTE: determine min box length to create the best layout + minw = min([v.view.w for v in V]) + + for e in E: + e.view = EdgeViewer() + + sug = SugiyamaLayout(g.C[0]) + gr = g.C[0] + r = list(filter(lambda x: len(x.e_in()) == 0, gr.sV)) + + sug.init_all(roots=r, optimize=True) + + sug.yspace = 2*VertexViewer.h + sug.xspace = minw + sug.route_edge = route_with_lines + + sug.draw() + + self.sug = sug + + def draw(self): + # NOTE: coordinates might me negative, so we need to shift + # everything to the positive plane before we actually draw it. + Xs = [] + Ys = [] + + for v in self.sug.g.sV: + # NOTE: moving boxes w/2 to the left + Xs.append(v.view.xy[0] - v.view.w/2.0) + Xs.append(v.view.xy[0] + v.view.w/2.0) + Ys.append(v.view.xy[1]) + Ys.append(v.view.xy[1] + v.view.h) + + for e in self.sug.g.sE: + for xy in e.view._pts: + Xs.append(xy[0]) + Ys.append(xy[1]) + + minx = min(Xs) + miny = min(Ys) + maxx = max(Xs) + maxy = max(Ys) + + canvas_cols = int(math.ceil(math.ceil(maxx) - math.floor(minx))) + 1 + canvas_lines = int(round(maxy - miny)) + + canvas = AsciiCanvas(canvas_cols, canvas_lines) + + # NOTE: first draw edges so that node boxes could overwrite them + for e in self.sug.g.sE: + assert len(e.view._pts) > 1 + for index in range(1, len(e.view._pts)): + start = e.view._pts[index - 1] + end = e.view._pts[index] + + start_x = int(round(start[0] - minx)) + start_y = int(round(start[1] - miny)) + end_x = int(round(end[0] - minx)) + end_y = int(round(end[1] - miny)) + + assert start_x >= 0 + assert start_y >= 0 + assert end_x >= 0 + assert end_y >= 0 + + canvas.add_line(start_x, start_y, + end_x, end_y, + style=Style('*')) + + for v in self.sug.g.sV: + # NOTE: moving boxes w/2 to the left + x = v.view.xy[0] - v.view.w/2.0 + y = v.view.xy[1] + + canvas.add_nine_patch_rect(int(round(x - minx)), + int(round(y - miny)), + v.view.w, + v.view.h) + + canvas.add_text(int(round(x - minx)) + 1, + int(round(y - miny)) + 1, + v.data) + + canvas.print_out() diff --git a/requirements.txt b/requirements.txt index 75710ec2e..dbabbe5f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,5 @@ reflink==0.2.0 requests>=2.18.4 wheel>=0.31.1 futures>=3.2.0; python_version == "2.7" +grandalf==0.55555 +asciicanvas==0.0.3 diff --git a/setup.py b/setup.py index ddbcc0360..7f3a181ab 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,8 @@ install_requires = [ "jsonpath-rw==1.4.0", "reflink==0.2.0", "requests>=2.18.4", + "grandalf==0.55555", + "asciicanvas==0.0.3", ] # Extra dependencies for remote integrations