From 5ac0a09831d06af1bc6f641a51e8a6c9914117a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:43:59 +0000 Subject: [PATCH 1/4] feat: add OpenDirectory config field and remove open CLI dest argument - Add `OpenDirectory: Path | None = None` to `Configuration` with tilde expansion. When set, devices are mounted in named subdirectories under this path for both the `open` and `backup` commands. - Remove the `dest` positional CLI argument from the `open` command. Users should use `OpenDirectory` in the config instead. - Fix the TOML parser to extract all top-level fields (previously fields like `SudoPassCmd` were silently dropped in TOML configs). - Use `mkdir(parents=True, exist_ok=True)` in `_open_device` so the full path including `OpenDirectory` is created as needed. - Update `backup` command to use `OpenDirectory / cfg.Name` as mount destination when configured; temp dir (cleaned up afterwards) otherwise. - Update tests and example configs to reflect the new behaviour. --- projects/butter-backup/examples/config.json5 | 1 + projects/butter-backup/examples/config.toml | 1 + projects/butter-backup/examples/config.yaml | 1 + .../butter-backup/src/butter_backup/cli.py | 18 ++++++++++------- .../src/butter_backup/config_parser.py | 17 +++++++++++++++- .../tests/cli/test_cli_commands.py | 20 +++++++++---------- .../tests/cli/test_sudo_pass_cmd.py | 10 ++++++---- 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/projects/butter-backup/examples/config.json5 b/projects/butter-backup/examples/config.json5 index f8961cf3..c21e015b 100644 --- a/projects/butter-backup/examples/config.json5 +++ b/projects/butter-backup/examples/config.json5 @@ -1,6 +1,7 @@ // JSON5 allows comments and trailing commas { SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg", + // OpenDirectory: "/media/ButterBackup", // optional: where to open devices DeviceConfigurations: [ { Name: "BtrFS Backup Example", diff --git a/projects/butter-backup/examples/config.toml b/projects/butter-backup/examples/config.toml index f7e1ee1d..9e9390d3 100644 --- a/projects/butter-backup/examples/config.toml +++ b/projects/butter-backup/examples/config.toml @@ -3,6 +3,7 @@ [butter-backup] SudoPassCmd = "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg" +# OpenDirectory = "/media/ButterBackup" # optional: where to open devices [[butter-backup.device-configurations]] Name = "BtrFS Backup Example" diff --git a/projects/butter-backup/examples/config.yaml b/projects/butter-backup/examples/config.yaml index 628ba57c..3db18fb8 100644 --- a/projects/butter-backup/examples/config.yaml +++ b/projects/butter-backup/examples/config.yaml @@ -1,5 +1,6 @@ --- SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg" +# OpenDirectory: "/media/ButterBackup" # optional: where to open devices DeviceConfigurations: - Name: "BtrFS Backup Example" UUID: "12345678-1234-5678-1234-567812345678" diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index 5a36d55c..a8e95ae7 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -141,7 +141,7 @@ def _open_device( cfg: cp.DeviceConfiguration, base_dir: Path, sudo_pass_cmd: str | None ) -> None: mount_dir = base_dir / cfg.Name - mount_dir.mkdir(exist_ok=True) + mount_dir.mkdir(parents=True, exist_ok=True) try: _refresh_sudo(sudo_pass_cmd) decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd) @@ -161,7 +161,6 @@ def _open_device( @app.command() def open( # noqa: A001 - dest: Path | None = typer.Argument(None), # noqa: B008 config: Path | None = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION, ) -> None: @@ -179,13 +178,14 @@ def open( # noqa: A001 oder durch Verwendung von `restic`. Nach erfolgreicher Wiederherstellung kann das Speichermedium mit `butter-backup close` wieder entfernt werden. - Optional kann ein Zielverzeichnis angegeben werden. Wenn angegeben, werden - die Speichermedien in Unterverzeichnissen dieses Verzeichnisses gemountet. - Andernfalls wird ein temporäres Verzeichnis erstellt. + Das Zielverzeichnis für die Speichermedien kann in der Konfiguration über + das Feld `OpenDirectory` festgelegt werden. Wenn nicht angegeben, wird ein + temporäres Verzeichnis erstellt. """ setup_logging(verbose) parsed_config = _read_configuration(config) - base_dir = dest if dest is not None else Path(mkdtemp()) + open_dir = parsed_config.OpenDirectory + base_dir = open_dir if open_dir is not None else Path(mkdtemp()) for cfg in parsed_config.DeviceConfigurations: if _skip_device( cfg, @@ -265,9 +265,13 @@ def backup( continue backend = bb.BackupBackend.from_config(cfg) _refresh_sudo(parsed_config.SudoPassCmd) + open_dir = parsed_config.OpenDirectory + dest = open_dir / cfg.Name if open_dir is not None else None with ( sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted, - sdm.mounted_device(decrypted, compression=cfg.compression()) as mount_dir, + sdm.mounted_device( + decrypted, dest, compression=cfg.compression() + ) as mount_dir, ): backend.do_backup(mount_dir, parsed_config.SudoPassCmd) # A backup could take so long that the sudo session expires. In this diff --git a/projects/butter-backup/src/butter_backup/config_parser.py b/projects/butter-backup/src/butter_backup/config_parser.py index ab87e84f..bce2b871 100644 --- a/projects/butter-backup/src/butter_backup/config_parser.py +++ b/projects/butter-backup/src/butter_backup/config_parser.py @@ -183,8 +183,16 @@ def compression(self) -> None: class Configuration(BaseModel): model_config = ConfigDict(frozen=True) DeviceConfigurations: list[DeviceConfiguration] + OpenDirectory: Path | None = None SudoPassCmd: str | None = None + @field_validator("OpenDirectory", mode="before") + @classmethod + def expand_tilde_in_open_directory(cls, v: str | None) -> str | None: + if v is None: + return None + return str(Path(v).expanduser()) + @model_validator(mode="after") def check_unique_names(self) -> t.Self: name_counts = Counter(cfg.Name for cfg in self.DeviceConfigurations) @@ -196,6 +204,9 @@ def check_unique_names(self) -> t.Self: return self +_TOML_DEVICE_CONFIGURATIONS_KEY = "device-configurations" + + def _parse_as_json(content: str) -> Any: return json.loads(content) @@ -207,7 +218,11 @@ def _parse_as_json5(content: str) -> Any: def _parse_as_toml(content: str) -> Any: data = tomllib.loads(content) bb = data["butter-backup"] - return {"DeviceConfigurations": bb["device-configurations"]} + result: dict[str, Any] = { + k: v for k, v in bb.items() if k != _TOML_DEVICE_CONFIGURATIONS_KEY + } + result["DeviceConfigurations"] = bb[_TOML_DEVICE_CONFIGURATIONS_KEY] + return result def _parse_as_yaml(content: str) -> Any: diff --git a/projects/butter-backup/tests/cli/test_cli_commands.py b/projects/butter-backup/tests/cli/test_cli_commands.py index 41a08501..62d0bac4 100644 --- a/projects/butter-backup/tests/cli/test_cli_commands.py +++ b/projects/butter-backup/tests/cli/test_cli_commands.py @@ -260,17 +260,17 @@ def test_open_with_explicit_dest( ) -> None: config = encrypted_device expected_cryptsetup_map = Path(f"/dev/mapper/{config.UUID}") - config_file = tmp_path / "config.json" - wrapped_config = cp.Configuration(DeviceConfigurations=[config]) - config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() expected_mount_dir = dest_dir / config.Name if create_dest_subdir: expected_mount_dir.mkdir() - open_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(config_file)] + config_file = tmp_path / "config.json" + wrapped_config = cp.Configuration( + DeviceConfigurations=[config], OpenDirectory=dest_dir ) + config_file.write_text(wrapped_config.model_dump_json()) + open_result = runner.invoke(app, ["open", "--config", str(config_file)]) assert open_result.exit_code == 0 assert str(expected_mount_dir) in open_result.stdout assert expected_cryptsetup_map.exists() @@ -290,14 +290,14 @@ def test_open_shows_error_on_failure(runner, encrypted_device, tmp_path: Path) - config = encrypted_device.model_copy( update={"DevicePassCmd": "echo wrong_password"} ) - config_file = tmp_path / "config.json" - wrapped_config = cp.Configuration(DeviceConfigurations=[config]) - config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() - open_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(config_file)] + config_file = tmp_path / "config.json" + wrapped_config = cp.Configuration( + DeviceConfigurations=[config], OpenDirectory=dest_dir ) + config_file.write_text(wrapped_config.model_dump_json()) + open_result = runner.invoke(app, ["open", "--config", str(config_file)]) expected_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden. Es wird übersprungen." assert open_result.exit_code == 0 assert expected_msg in open_result.stdout diff --git a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py index 8602007f..f6c04bd6 100644 --- a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py +++ b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py @@ -35,13 +35,15 @@ def test_sudo_pass_cmd_is_used_in_open( tmp_path: Path, ) -> None: sudo_pass_cmd = "echo test_password" + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() wrapped_config = cp.Configuration( - DeviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + DeviceConfigurations=[encrypted_device], + OpenDirectory=dest_dir, + SudoPassCmd=sudo_pass_cmd, ) config_file = tmp_path / "config.json" config_file.write_text(wrapped_config.model_dump_json()) - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") mocker.patch( @@ -50,7 +52,7 @@ def test_sudo_pass_cmd_is_used_in_open( ) mocker.patch("storage_device_managers.mount_device") - runner.invoke(app, ["open", str(dest_dir), "--config", str(config_file)]) + runner.invoke(app, ["open", "--config", str(config_file)]) mock_pipe.assert_called_once_with( sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True From 3d121de134bfc1f47975f722bc6b25909f49828a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:48:17 +0000 Subject: [PATCH 2/4] test: add regression test for TOML parser dropping top-level fields --- .../butter-backup/src/butter_backup/cli.py | 8 +++-- .../tests/cli/test_sudo_pass_cmd.py | 29 +++++++++++++++++++ .../config_parser/test_parse_configuration.py | 16 ++++++++++ .../tests/test_mount_unmount.py | 29 +++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index a8e95ae7..59eff193 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -141,9 +141,10 @@ def _open_device( cfg: cp.DeviceConfiguration, base_dir: Path, sudo_pass_cmd: str | None ) -> None: mount_dir = base_dir / cfg.Name - mount_dir.mkdir(parents=True, exist_ok=True) + created_mount_dir = False try: _refresh_sudo(sudo_pass_cmd) + created_mount_dir = sdm.ensure_directory(mount_dir) decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd) sdm.mount_device(decrypted, mount_dir=mount_dir, compression=cfg.compression()) except Exception: @@ -153,8 +154,9 @@ def _open_device( typer.echo( f"Speichermedium {cfg.Name} konnte nicht geöffnet werden. Es wird übersprungen." ) - with contextlib.suppress(OSError): - mount_dir.rmdir() + if created_mount_dir: + with contextlib.suppress(sh.ShellInterfaceError): + sh.run_cmd(cmd=["sudo", "rmdir", mount_dir]) else: typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.") diff --git a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py index f6c04bd6..ace0e65a 100644 --- a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py +++ b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py @@ -59,6 +59,35 @@ def test_sudo_pass_cmd_is_used_in_open( ) +def test_open_uses_sudo_to_create_mount_dir( + runner: CliRunner, + encrypted_btrfs_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + dest_dir = tmp_path / "mounts" + wrapped_config = cp.Configuration( + DeviceConfigurations=[encrypted_btrfs_device], + OpenDirectory=dest_dir, + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + + ensure_directory = mocker.patch( + "storage_device_managers.ensure_directory", return_value=True + ) + mocker.patch( + "storage_device_managers.open_encrypted_device", + return_value=Path("/dev/mapper/test"), + ) + mocker.patch("storage_device_managers.mount_device") + + result = runner.invoke(app, ["open", "--config", str(config_file)]) + + assert result.exit_code == 0 + ensure_directory.assert_called_once_with(dest_dir / encrypted_btrfs_device.Name) + + def test_sudo_pass_cmd_is_used_in_backup( runner: CliRunner, encrypted_device: cp.DeviceConfiguration, diff --git a/projects/butter-backup/tests/config_parser/test_parse_configuration.py b/projects/butter-backup/tests/config_parser/test_parse_configuration.py index cb2818c2..a64ab20b 100644 --- a/projects/butter-backup/tests/config_parser/test_parse_configuration.py +++ b/projects/butter-backup/tests/config_parser/test_parse_configuration.py @@ -128,3 +128,19 @@ def test_parse_configuration_with_sudo_pass_cmd( raw = cfg.model_dump_json() result = cp.parse_configuration(raw) assert result == cfg + + +def test_toml_parsing_preserves_sudo_pass_cmd() -> None: + toml_content = """\ +[butter-backup] +SudoPassCmd = "echo sudo-password" + +[[butter-backup.device-configurations]] +UUID = "12345678-1234-5678-1234-567812345678" +DevicePassCmd = "echo device-password" +BackupRepositoryFolder = "repo" +RepositoryPassCmd = "echo repo-password" +FilesAndFolders = [] +""" + result = cp.parse_configuration(toml_content) + assert result.SudoPassCmd == "echo sudo-password" diff --git a/projects/storage-device-managers/tests/test_mount_unmount.py b/projects/storage-device-managers/tests/test_mount_unmount.py index 096e5ee3..ab50b25b 100644 --- a/projects/storage-device-managers/tests/test_mount_unmount.py +++ b/projects/storage-device-managers/tests/test_mount_unmount.py @@ -50,6 +50,21 @@ class MyCustomTestException(Exception): pass +def test_ensure_directory_uses_sudo_for_missing_directory( + mocker, tmp_path: Path +) -> None: + destination = tmp_path / "nested" / "mount" + run_cmd = mocker.patch("storage_device_managers.sh.run_cmd") + assert sdm.ensure_directory(destination) + run_cmd.assert_called_once_with(cmd=["sudo", "mkdir", "-p", destination]) + + +def test_ensure_directory_skips_existing_directory(mocker, tmp_path: Path) -> None: + run_cmd = mocker.patch("storage_device_managers.sh.run_cmd") + assert not sdm.ensure_directory(tmp_path) + run_cmd.assert_not_called() + + def test_mount_device( device_with_fs, tmp_path: Path, compression_kwargs: CompressionKwargsT ) -> None: @@ -180,3 +195,17 @@ def test_mounted_device_with_destination( assert str(device) not in sdm.get_mounted_devices() # A given destination should not be deleted after unmounting. assert destination.exists() + + +def test_mounted_device_uses_sudo_to_create_destination(mocker, tmp_path: Path) -> None: + device = tmp_path / "device" + destination = tmp_path / "nested" / "mount" + ensure_directory = mocker.patch( + "storage_device_managers.ensure_directory", return_value=True + ) + mocker.patch("storage_device_managers.is_mounted", return_value=False) + mocker.patch("storage_device_managers.mount_device") + mocker.patch("storage_device_managers.unmount_device") + with sdm.mounted_device(device, destination) as mount_dir: + assert mount_dir == destination + ensure_directory.assert_called_once_with(destination) From b8cc4b0d2979528d016013a95e37f80922b2fb64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:11:53 +0000 Subject: [PATCH 3/4] fix: use sudo helper for mount directory creation --- projects/butter-backup/src/butter_backup/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index 59eff193..4eefffef 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -156,7 +156,8 @@ def _open_device( ) if created_mount_dir: with contextlib.suppress(sh.ShellInterfaceError): - sh.run_cmd(cmd=["sudo", "rmdir", mount_dir]) + cmd: sh.StrPathList = ["sudo", "rmdir", mount_dir] + sh.run_cmd(cmd=cmd) else: typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.") From 57e86ea50d0148ef8ebae59527ad367923858f17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:00:54 +0000 Subject: [PATCH 4/4] refactor: rewrite mocked tests without mocking --- .../tests/cli/test_sudo_pass_cmd.py | 29 ------------------- .../tests/test_mount_unmount.py | 26 +++-------------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py index ace0e65a..f6c04bd6 100644 --- a/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py +++ b/projects/butter-backup/tests/cli/test_sudo_pass_cmd.py @@ -59,35 +59,6 @@ def test_sudo_pass_cmd_is_used_in_open( ) -def test_open_uses_sudo_to_create_mount_dir( - runner: CliRunner, - encrypted_btrfs_device: cp.DeviceConfiguration, - mocker, - tmp_path: Path, -) -> None: - dest_dir = tmp_path / "mounts" - wrapped_config = cp.Configuration( - DeviceConfigurations=[encrypted_btrfs_device], - OpenDirectory=dest_dir, - ) - config_file = tmp_path / "config.json" - config_file.write_text(wrapped_config.model_dump_json()) - - ensure_directory = mocker.patch( - "storage_device_managers.ensure_directory", return_value=True - ) - mocker.patch( - "storage_device_managers.open_encrypted_device", - return_value=Path("/dev/mapper/test"), - ) - mocker.patch("storage_device_managers.mount_device") - - result = runner.invoke(app, ["open", "--config", str(config_file)]) - - assert result.exit_code == 0 - ensure_directory.assert_called_once_with(dest_dir / encrypted_btrfs_device.Name) - - def test_sudo_pass_cmd_is_used_in_backup( runner: CliRunner, encrypted_device: cp.DeviceConfiguration, diff --git a/projects/storage-device-managers/tests/test_mount_unmount.py b/projects/storage-device-managers/tests/test_mount_unmount.py index ab50b25b..ac80ebcf 100644 --- a/projects/storage-device-managers/tests/test_mount_unmount.py +++ b/projects/storage-device-managers/tests/test_mount_unmount.py @@ -50,19 +50,15 @@ class MyCustomTestException(Exception): pass -def test_ensure_directory_uses_sudo_for_missing_directory( - mocker, tmp_path: Path -) -> None: +def test_ensure_directory_creates_missing_directory(tmp_path: Path) -> None: destination = tmp_path / "nested" / "mount" - run_cmd = mocker.patch("storage_device_managers.sh.run_cmd") + assert not destination.exists() assert sdm.ensure_directory(destination) - run_cmd.assert_called_once_with(cmd=["sudo", "mkdir", "-p", destination]) + assert destination.exists() -def test_ensure_directory_skips_existing_directory(mocker, tmp_path: Path) -> None: - run_cmd = mocker.patch("storage_device_managers.sh.run_cmd") +def test_ensure_directory_skips_existing_directory(tmp_path: Path) -> None: assert not sdm.ensure_directory(tmp_path) - run_cmd.assert_not_called() def test_mount_device( @@ -195,17 +191,3 @@ def test_mounted_device_with_destination( assert str(device) not in sdm.get_mounted_devices() # A given destination should not be deleted after unmounting. assert destination.exists() - - -def test_mounted_device_uses_sudo_to_create_destination(mocker, tmp_path: Path) -> None: - device = tmp_path / "device" - destination = tmp_path / "nested" / "mount" - ensure_directory = mocker.patch( - "storage_device_managers.ensure_directory", return_value=True - ) - mocker.patch("storage_device_managers.is_mounted", return_value=False) - mocker.patch("storage_device_managers.mount_device") - mocker.patch("storage_device_managers.unmount_device") - with sdm.mounted_device(device, destination) as mount_dir: - assert mount_dir == destination - ensure_directory.assert_called_once_with(destination)