diff --git a/shelephant/cli.py b/shelephant/cli.py index cf16a3c..2ff7def 100644 --- a/shelephant/cli.py +++ b/shelephant/cli.py @@ -809,6 +809,7 @@ class MyFmt( "mv", "rm", "pwd", + "diff", "gitignore", "add", "remove", @@ -841,6 +842,8 @@ def _shelephant_main(): dataset.rm(sys.argv[2:]) elif args.command == "pwd": dataset.pwd(sys.argv[2:]) + elif args.command == "diff": + dataset.diff(sys.argv[2:]) elif args.command == "gitignore": dataset.gitignore(sys.argv[2:]) elif args.command == "add": diff --git a/shelephant/dataset.py b/shelephant/dataset.py index 3b1bbc2..244ce52 100644 --- a/shelephant/dataset.py +++ b/shelephant/dataset.py @@ -1648,6 +1648,62 @@ def pwd(args: list[str]): print(os.path.relpath(root / post, os.getcwd())) +def _diff_parser(): + """ + Return parser for :py:func:`shelephant diff`. + """ + + desc = textwrap.dedent( + """ + Show differences between two storage locations. + """ + ) + + class MyFmt( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter, + argparse.MetavarTypeHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser(formatter_class=MyFmt, description=desc) + + parser.add_argument("--version", action="version", version=version) + parser.add_argument("--colors", type=str, default="dark", help="Color scheme [none, dark].") + parser.add_argument("--pop", type=str, action="append", help='Pop direction (e.g. "==").') + parser.add_argument("source", type=str, help="Storage location.") + parser.add_argument("dest", type=str, help="Storage location.") + return parser + + +def diff(args: list[str]): + """ + Command-line tool, see ``--help``. + + :param args: Command-line arguments (should be all strings). + """ + + parser = _diff_parser() + args = parser.parse_args(args) + sdir = _search_upwards_dir(".shelephant") + assert sdir is not None, "Not in a shelephant dataset" + + with search.cwd(sdir): + storage = yaml.read(sdir / "storage.yaml") + assert args.source in storage, f"Unknown storage location {args.source}" + assert args.dest in storage, f"Unknown storage location {args.dest}" + source = Location.from_yaml(f"storage/{args.source}.yaml") + dest = Location.from_yaml(f"storage/{args.dest}.yaml") + + status = source.diff(dest) + if args.pop is not None: + for i in args.pop: + status.pop(i) + for key in status: + status[key] = sorted(status[key]) + output.diff(status, colors=args.colors) + + def _status_parser(): """ Return parser for :py:func:`shelephant status`. diff --git a/shelephant/output.py b/shelephant/output.py index 2551899..5521a26 100644 --- a/shelephant/output.py +++ b/shelephant/output.py @@ -162,3 +162,99 @@ def copyplan( return sio.getvalue() autoprint(sio.getvalue()) + + +def diff( + status: dict[list[str]], + colors: str = "none", + display: bool = True, + max_align: int = 80, +) -> str: + """ + Print copy plan. + + :param status: + Dictionary of status. E.g.:: + + { + '==' : ['file4'], + '?=' : [], + '!=' : ['file3'], + '->' : ['file1', 'file2'], + '<-' : [], + } + + :param colors: Color theme name, see :py:func:`theme`. + :param display: Display output (``False``: return as string). + :param max_align: Maximum width of the first column. + :return: Output string (if ``display=False``). + """ + color = _theme(colors.lower()) + sio = io.StringIO() + + skip = status.pop("==", []) + right = status.pop("->", []) + left = status.pop("<-", []) + ne = status.pop("!=", []) + na = status.pop("?=", []) + + if len(ne) + len(na) + len(left) + len(right) + len(skip) == 0: + return + + width = max(len(file) for file in ne + na + left + right + skip) + width = min(width, max_align) + + for file in ne: + print( + "{:s} {:s} {:s}".format( + _format(file, width=width, color=color["overwrite"]), + _format("!=", color=color["bright"]), + _format(file, color=color["overwrite"]), + ), + file=sio, + ) + + for file in na: + print( + "{:s} {:s} {:s}".format( + _format(file, width=width, color=color["overwrite"]), + _format("?=", color=color["bright"]), + _format(file, color=color["overwrite"]), + ), + file=sio, + ) + + for file in left: + print( + "{:s} {:s} {:s}".format( + _format(file, width=width, color=color["new"]), + _format("<-", color=color["bright"]), + _format(file, color=color["bright"]), + ), + file=sio, + ) + + for file in right: + print( + "{:s} {:s} {:s}".format( + _format(file, width=width, color=color["bright"]), + _format("->", color=color["bright"]), + _format(file, color=color["new"]), + ), + file=sio, + ) + + for file in skip: + print( + "{:s} {:s} {:s}".format( + _format(file, width=width, color=color["skip"]), + _format("==", color=color["skip"]), + _format(file, color=color["skip"]), + ), + file=sio, + ) + + if not display: + return sio.getvalue() + + autoprint(sio.getvalue()) diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 3122fbf..d36b8bb 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -219,6 +219,20 @@ def test_basic(self): ret = _plain(sio.getvalue())[1:] self.assertEqual(ret, expect) + with cwd(dataset), contextlib.redirect_stdout(io.StringIO()) as sio: + shelephant.dataset.diff(["--colors", "none", "source1", "source2"]) + + expect = [ + "e.txt <- e.txt", + "f.txt <- f.txt", + "c.txt -> c.txt", + "d.txt -> d.txt", + "a.txt == a.txt", + "b.txt == b.txt", + ] + ret = _plain(sio.getvalue()) + self.assertEqual(ret, expect) + with cwd(dataset): for f in ["a.txt", "b.txt", "c.txt", "d.txt"]: self.assertEqual(pathlib.Path(f).readlink().parent.name, "source1")