diff --git a/dploy/__init__.py b/dploy/__init__.py index 3eb373f..19e2d90 100644 --- a/dploy/__init__.py +++ b/dploy/__init__.py @@ -10,18 +10,32 @@ assert sys.version_info >= (3, 3), "Requires Python 3.3 or Greater" -def stow(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None): +def stow( + sources, + dest, + is_silent=True, + is_dry_run=False, + ignore_patterns=None, + dotfiles=False, +): """ sub command stow """ - stowcmd.Stow(sources, dest, is_silent, is_dry_run, ignore_patterns) + stowcmd.Stow(sources, dest, is_silent, is_dry_run, ignore_patterns, dotfiles) -def unstow(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None): +def unstow( + sources, + dest, + is_silent=True, + is_dry_run=False, + ignore_patterns=None, + dotfiles=False, +): """ sub command unstow """ - stowcmd.UnStow(sources, dest, is_silent, is_dry_run, ignore_patterns) + stowcmd.UnStow(sources, dest, is_silent, is_dry_run, ignore_patterns, dotfiles) def clean(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None): diff --git a/dploy/cli.py b/dploy/cli.py index e84db94..eec3d05 100644 --- a/dploy/cli.py +++ b/dploy/cli.py @@ -49,6 +49,12 @@ def create_parser(): stow_parser = sub_parsers.add_parser("stow") stow_parser.add_argument("source", nargs="+", help="source directory to stow") stow_parser.add_argument("dest", help="destination path to stow into") + stow_parser.add_argument( + "--dotfiles", + dest="dotfiles", + action="store_true", + help="Treating a source file or folder named 'dot-prefix-something' is equivalent to a destination file or folder named '.prefix-something'", + ) add_ignore_argument(stow_parser) unstow_parser = sub_parsers.add_parser("unstow") @@ -56,6 +62,12 @@ def create_parser(): "source", nargs="+", help="source directory to unstow from" ) unstow_parser.add_argument("dest", help="destination path to unstow") + unstow_parser.add_argument( + "--dotfiles", + dest="dotfiles", + action="store_true", + help="Treating a source file or folder named 'dot-prefix-something' is equivalent to a destination file or folder named '.prefix-something'", + ) add_ignore_argument(unstow_parser) clean_parser = sub_parsers.add_parser("clean") @@ -99,16 +111,26 @@ def run(arguments=None): sys.exit(0) try: - subcmd( - args.source, - args.dest, - is_silent=args.is_silent, - is_dry_run=args.is_dry_run, - ignore_patterns=args.ignore_patterns, - ) + if args.subcmd in ["stow", "unstow"]: + subcmd( + args.source, + args.dest, + is_silent=args.is_silent, + is_dry_run=args.is_dry_run, + ignore_patterns=args.ignore_patterns, + dotfiles=args.dotfiles, + ) + else: + subcmd( + args.source, + args.dest, + is_silent=args.is_silent, + is_dry_run=args.is_dry_run, + ignore_patterns=args.ignore_patterns, + ) except DployError: sys.exit(1) - except (KeyboardInterrupt) as error: + except KeyboardInterrupt as error: print(error, file=sys.stderr) sys.exit(130) diff --git a/dploy/main.py b/dploy/main.py index b7773d8..e867984 100644 --- a/dploy/main.py +++ b/dploy/main.py @@ -71,7 +71,16 @@ class AbstractBaseSubCommand: """ # pylint: disable=too-many-arguments - def __init__(self, subcmd, sources, dest, is_silent, is_dry_run, ignore_patterns): + def __init__( + self, + subcmd, + sources, + dest, + is_silent, + is_dry_run, + ignore_patterns, + dotfiles=False, + ): self.subcmd = subcmd self.actions = actions.Actions(is_silent, is_dry_run) @@ -79,6 +88,7 @@ def __init__(self, subcmd, sources, dest, is_silent, is_dry_run, ignore_patterns self.is_silent = is_silent self.is_dry_run = is_dry_run + self.dotfiles = dotfiles self.dest_input = pathlib.Path(dest) source_inputs = [pathlib.Path(source) for source in sources] diff --git a/dploy/stowcmd.py b/dploy/stowcmd.py index 3640b39..feefb9d 100644 --- a/dploy/stowcmd.py +++ b/dploy/stowcmd.py @@ -19,9 +19,20 @@ class AbstractBaseStow(main.AbstractBaseSubCommand): """ # pylint: disable=too-many-arguments - def __init__(self, subcmd, source, dest, is_silent, is_dry_run, ignore_patterns): + def __init__( + self, + subcmd, + source, + dest, + is_silent, + is_dry_run, + ignore_patterns, + dotfiles=False, + ): self.is_unfolding = False - super().__init__(subcmd, source, dest, is_silent, is_dry_run, ignore_patterns) + super().__init__( + subcmd, source, dest, is_silent, is_dry_run, ignore_patterns, dotfiles + ) def _is_valid_input(self, sources, dest): """ @@ -105,7 +116,10 @@ def _collect_actions(self, source, dest): self.ignore.ignore(subsources) continue - dest_path = dest / pathlib.Path(subsources.name) + dest_name = subsources.name + if self.dotfiles and dest_name.startswith("dot-"): + dest_name = dest_name.replace("dot-", ".", 1) + dest_path = dest / pathlib.Path(dest_name) does_dest_path_exist = False try: @@ -134,9 +148,17 @@ class Stow(AbstractBaseStow): # pylint: disable=too-many-arguments def __init__( - self, source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None + self, + source, + dest, + is_silent=True, + is_dry_run=False, + ignore_patterns=None, + dotfiles=False, ): - super().__init__("stow", source, dest, is_silent, is_dry_run, ignore_patterns) + super().__init__( + "stow", source, dest, is_silent, is_dry_run, ignore_patterns, dotfiles + ) def _unfold(self, source, dest): """ @@ -221,9 +243,17 @@ class UnStow(AbstractBaseStow): # pylint: disable=too-many-arguments def __init__( - self, source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None + self, + source, + dest, + is_silent=True, + is_dry_run=False, + ignore_patterns=None, + dotfiles=False, ): - super().__init__("unstow", source, dest, is_silent, is_dry_run, ignore_patterns) + super().__init__( + "unstow", source, dest, is_silent, is_dry_run, ignore_patterns, dotfiles + ) def _are_same_file(self, source, dest): """ @@ -379,7 +409,15 @@ def __init__(self, source, dest, is_silent, is_dry_run, ignore_patterns): self.source = [pathlib.Path(s) for s in source] self.dest = pathlib.Path(dest) self.ignore_patterns = ignore_patterns - super().__init__("clean", source, dest, is_silent, is_dry_run, ignore_patterns) + super().__init__( + "clean", + source, + dest, + is_silent, + is_dry_run, + ignore_patterns, + dotfiles=False, + ) def _is_valid_input(self, sources, dest): """ diff --git a/tests/conftest.py b/tests/conftest.py index f0b6076..2d5da55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -179,3 +179,44 @@ def file_dploystowignore(tmpdir): name = str(tmpdir.join(".dploystowignore")) utils.create_file(name) return name + + +@pytest.fixture(scope="function") +def source_with_dotfiles(tmpdir): + """ + a source directory to stow and unstow that contains files and folders named with prefix 'dot-' + """ + name = str(tmpdir.join("source_with_dotfiles")) + tree = [ + { + name: [ + { + "aaa": [ + "dot-aaa", + "bbb", + { + "dot-ccc": [ + "dot-aaa", + "bbb", + ], + }, + ], + }, + "dot-bbb", + ], + }, + ] + utils.create_tree(tree) + yield name + utils.remove_tree(name) + + +@pytest.fixture(scope="function") +def dest_with_dotfiles(tmpdir): + """ + a destination directory to stow into or unstow from with dotfiles + """ + name = str(tmpdir.join("dest_with_dotfiles")) + utils.create_directory(name) + yield name + utils.remove_tree(name) diff --git a/tests/test_cli.py b/tests/test_cli.py index af60390..b130851 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """ Tests for the CLI interface """ + # pylint: disable=missing-docstring # disable lint errors for function names longer that 30 characters # pylint: disable=invalid-name @@ -88,3 +89,58 @@ def test_cli_with_version_option(capsys): dploy.cli.run(args) out, _ = capsys.readouterr() assert re.match(r"dploy \d+.\d+\.\d+(-\w+)?\n", out) is not None + + +def test_cli_stow_with_dotfiles_option( + source_with_dotfiles, dest_with_dotfiles, capsys +): + args = ["stow", "--dotfiles", source_with_dotfiles, dest_with_dotfiles] + dploy.cli.run(args) + assert os.readlink(os.path.join(dest_with_dotfiles, "aaa")) == os.path.join( + "..", "source_with_dotfiles", "aaa" + ) + assert os.readlink(os.path.join(dest_with_dotfiles, ".bbb")) == os.path.join( + "..", "source_with_dotfiles", "dot-bbb" + ) + + out, _ = capsys.readouterr() + d = os.path.join(dest_with_dotfiles, "aaa") + s = os.path.relpath(os.path.join(source_with_dotfiles, "aaa"), dest_with_dotfiles) + d2 = os.path.join(dest_with_dotfiles, ".bbb") + s2 = os.path.relpath( + os.path.join(source_with_dotfiles, "dot-bbb"), dest_with_dotfiles + ) + assert ( + out + == "dploy stow: link {dest} => {source}\ndploy stow: link {dest2} => {source2}\n".format( + source=s, dest=d, source2=s2, dest2=d2 + ) + ) + + +def test_cli_unstow_with_dotfiles_option( + source_with_dotfiles, dest_with_dotfiles, capsys +): + args = ["stow", "--dotfiles", source_with_dotfiles, dest_with_dotfiles] + dploy.cli.run(args) + args_unstow = ["unstow", "--dotfiles", source_with_dotfiles, dest_with_dotfiles] + dploy.cli.run(args_unstow) + assert not os.path.exists(os.path.join(dest_with_dotfiles, "aaa")) + assert not os.path.exists(os.path.join(dest_with_dotfiles, ".bbb")) + + out, _ = capsys.readouterr() + d = os.path.join(dest_with_dotfiles, "aaa") + s = os.path.relpath(os.path.join(source_with_dotfiles, "aaa"), dest_with_dotfiles) + d2 = os.path.join(dest_with_dotfiles, ".bbb") + s2 = os.path.relpath( + os.path.join(source_with_dotfiles, "dot-bbb"), dest_with_dotfiles + ) + expected_output = ( + "dploy stow: link {dest} => {source}\n" + "dploy stow: link {dest2} => {source2}\n" + "dploy unstow: unlink {dest} => {source}\n" + "dploy unstow: unlink {dest2} => {source2}\n".format( + source=s, dest=d, source2=s2, dest2=d2 + ) + ) + assert out == (expected_output) diff --git a/tests/test_stow.py b/tests/test_stow.py index 820e2a6..6ba3621 100644 --- a/tests/test_stow.py +++ b/tests/test_stow.py @@ -1,6 +1,7 @@ """ Tests for the stow stub command """ + # pylint: disable=missing-docstring # disable lint errors for function names longer that 30 characters # pylint: disable=invalid-name @@ -238,3 +239,49 @@ def test_stow_unfolding_with_write_only_source_file(source_a, source_b, dest): ) with pytest.raises(error.InsufficientPermissionsToSubcmdFrom): dploy.stow([source_a, source_b], dest) + + +def test_stow_with_dotfiles(source_with_dotfiles, dest_with_dotfiles): + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert os.readlink(os.path.join(dest_with_dotfiles, ".bbb")) == os.path.join( + "..", "source_with_dotfiles", "dot-bbb" + ) + assert os.path.islink(os.path.join(dest_with_dotfiles, "aaa")) + assert os.readlink(os.path.join(dest_with_dotfiles, "aaa")) == os.path.join( + "..", "source_with_dotfiles", "aaa" + ) + assert not os.path.islink(os.path.join(dest_with_dotfiles, "aaa", "dot-aaa")) + + +def test_stow_with_dot_in_exist_fold_with_dotfiles( + source_with_dotfiles, dest_with_dotfiles +): + utils.create_directory(os.path.join(dest_with_dotfiles, "aaa")) + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert not os.path.islink(os.path.join(dest_with_dotfiles, "aaa")) + assert os.readlink(os.path.join(dest_with_dotfiles, "aaa", ".aaa")) == os.path.join( + "..", "..", "source_with_dotfiles", "aaa", "dot-aaa" + ) + + +def test_stow_with_dot_in_exist_fold_exist_other_with_dotfiles( + source_with_dotfiles, dest_with_dotfiles +): + utils.create_directory(os.path.join(dest_with_dotfiles, "aaa")) + utils.create_file(os.path.join(dest_with_dotfiles, "aaa", ".keep")) + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert os.readlink(os.path.join(dest_with_dotfiles, "aaa", ".aaa")) == os.path.join( + "..", "..", "source_with_dotfiles", "aaa", "dot-aaa" + ) + + assert os.readlink(os.path.join(dest_with_dotfiles, "aaa", ".ccc")) == os.path.join( + "..", "..", "source_with_dotfiles", "aaa", "dot-ccc" + ) + + assert not os.path.islink( + os.path.join(dest_with_dotfiles, "aaa", ".ccc", "dot-aaa") + ) + assert not os.path.islink(os.path.join(dest_with_dotfiles, "aaa", ".ccc", "bbb")) diff --git a/tests/test_unstow.py b/tests/test_unstow.py index df5356f..44c6652 100644 --- a/tests/test_unstow.py +++ b/tests/test_unstow.py @@ -1,6 +1,7 @@ """ Tests for the stow sub command """ + # pylint: disable=missing-docstring # disable lint errors for function names longer that 30 characters # pylint: disable=invalid-name @@ -220,3 +221,42 @@ def test_unstow_folding_with_multiple_sources_with_execute_permission_unset( message = str(error.PermissionDenied(subcmd=SUBCMD, file=dest_dir)) with pytest.raises(error.PermissionDenied, match=message): dploy.unstow([source_a], dest) + + +def test_unstow_with_dotfiles(source_with_dotfiles, dest_with_dotfiles): + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + dploy.unstow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert not os.path.exists(os.path.join(dest_with_dotfiles, "aaa")) + assert not os.path.exists(os.path.join(dest_with_dotfiles, ".bbb")) + + +def test_unstow_with_dot_in_exist_fold_with_dotfiles( + source_with_dotfiles, dest_with_dotfiles +): + utils.create_directory(os.path.join(dest_with_dotfiles, "aaa")) + + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + dploy.unstow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert not os.path.islink(os.path.join(dest_with_dotfiles, "aaa")) + # see https://github.com/arecarn/dploy/issues/15 + # assert len(os.listdir(os.path.join(dest_with_dotfiles, 'aaa'))) == 0 + assert not os.path.exists(os.path.join(dest_with_dotfiles, ".bbb")) + + +def test_unstow_with_dot_in_exist_fold_exist_other_with_dotfiles( + source_with_dotfiles, dest_with_dotfiles +): + utils.create_directory(os.path.join(dest_with_dotfiles, "aaa")) + utils.create_file(os.path.join(dest_with_dotfiles, "aaa", ".keep")) + dploy.stow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + dploy.unstow([source_with_dotfiles], dest_with_dotfiles, dotfiles=True) + + assert os.path.exists(os.path.join(dest_with_dotfiles, "aaa")) + assert not os.path.islink(os.path.join(dest_with_dotfiles, "aaa")) + + assert len(os.listdir(os.path.join(dest_with_dotfiles, "aaa"))) == 1 + assert os.path.exists(os.path.join(dest_with_dotfiles, "aaa", ".keep")) + assert not os.path.exists(os.path.join(dest_with_dotfiles, "aaa", ".aaa")) + assert not os.path.exists(os.path.join(dest_with_dotfiles, "aaa", ".ccc"))