diff --git a/core/fields.py b/core/fields.py index 7ce4770..3670997 100644 --- a/core/fields.py +++ b/core/fields.py @@ -16,6 +16,7 @@ class Field: choices: tuple[str, ...] = () help: str | None = None validator: Callable[[Any], Any] | None = None + when: Callable[[dict[str, Any]], bool] | None = None @property def flag(self) -> str: diff --git a/engines/files.py b/engines/files.py index d6d971e..cd71618 100644 --- a/engines/files.py +++ b/engines/files.py @@ -1,6 +1,7 @@ from __future__ import annotations import posixpath +import re from typing import Any from core.errors import ValidationError @@ -10,6 +11,7 @@ from services.ports import PortAllocator DATA_DIR = "/data" +SIZE_PATTERN = re.compile(r"\d+(\.\d+)?[bkmgtp]?", re.IGNORECASE | re.ASCII) def host_dir(value: str) -> str: @@ -36,6 +38,29 @@ def no_braces(patterns: list[str]) -> list[str]: return patterns +def file_count(value: str) -> int | str: + count = value.strip() + if not count: + return "" + if not count.isdecimal(): + raise ValidationError(f"Must be a whole number >= 0, got {value!r}") + return int(count) + + +def file_size(value: str) -> str: + size = value.strip() + if size and not SIZE_PATTERN.fullmatch(size): + raise ValidationError( + f"Invalid size {value!r}", + hint="e.g. 500M or 10G (B, K, M, G, T or P); no suffix means KiB.", + ) + return size + + +def sync_only(answers: dict[str, Any]) -> bool: + return answers.get("method") == "sync" + + class FilesEngine(DbEngine): key, display = "files", "Files" template = None @@ -43,7 +68,8 @@ class FilesEngine(DbEngine): label_default = "Files" warning = ( f"The directory is mounted read-write under {DATA_DIR}: a restore replaces " - "its content (excluded paths are kept). Requires Agent 1.24.0+." + "its content (excluded paths are kept). Requires Agent 1.24.0+; the copy " + "method and the sync delete limits require Agent 1.25.0+." ) def fields_existing(self) -> list[Field]: @@ -66,12 +92,15 @@ def option_fields(self) -> list[Field]: "Backup method", "choice", default="archive", - choices=("archive", "snapshot", "sync"), + choices=("archive", "snapshot", "sync", "copy"), help=( "archive: a tar.gz archive per run. snapshot: incremental, " "deduplicated restic snapshots. sync: mirrors the directory onto " "each storage; deletions propagate and it cannot be restored from " - "Portabase. Cannot be changed once the source exists." + "Portabase. copy: like sync, but files removed from the source are " + "kept on the storage; changed files are overwritten, with no " + "history, and it cannot be restored from Portabase. Cannot be " + "changed once the source exists." ), ), Field( @@ -93,6 +122,31 @@ def option_fields(self) -> list[Field]: default=False, help="Skip mount points found inside the directory.", ), + Field( + "max_delete", + "Max deletions per run", + "text", + default="", + validator=file_count, + when=sync_only, + help=( + "Sync only. A run that would delete more files, or more data, " + "than these limits fails before deleting anything. Leave empty " + "for no limit." + ), + ), + Field( + "max_delete_size", + "Max deleted size per run", + "text", + default="", + validator=file_size, + when=sync_only, + help=( + "Sync only. A size such as 500M or 10G (B, K, M, G, T or P); a " + "number without suffix is in KiB. Leave empty for no limit." + ), + ), ] def generate( diff --git a/tests/core/fields.py b/tests/core/fields.py index 2f53258..33e89b5 100644 --- a/tests/core/fields.py +++ b/tests/core/fields.py @@ -7,13 +7,14 @@ def defaults(): field = Field("name", "Prompt") - assert (field.kind, field.default, field.choices, field.help, field.validator) == ( - "text", - None, - (), - None, - None, - ) + assert ( + field.kind, + field.default, + field.choices, + field.help, + field.validator, + field.when, + ) == ("text", None, (), None, None, None) @pytest.mark.parametrize( diff --git a/tests/engines/files.py b/tests/engines/files.py index 3a0bd1c..f1f51fa 100644 --- a/tests/engines/files.py +++ b/tests/engines/files.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest from core.errors import ValidationError @@ -5,9 +7,12 @@ from engines import registry from engines.files import FilesEngine from tests.support import agent_service, field_specs +from ui.components.prompt import Prompt +from ui.form import Form FILES = registry.get("files") MOUNT = "/srv/uploads:/data/srv/uploads" +FORM = Form(cast(Prompt, None), non_interactive=True) def attributes(): @@ -16,6 +21,7 @@ def attributes(): assert FILES.template is None assert (FILES.auth_variants, FILES.has_modes) == (False, False) assert "restore" in (FILES.warning or "") + assert "1.25.0" in (FILES.warning or "") def generate(ports): @@ -46,6 +52,14 @@ def agent_entry(): "method": "snapshot", "exclude": ["*.tmp"], } + limited = bare.with_options( + {"method": "sync", "max_delete": 100, "max_delete_size": "10G"} + ) + assert FILES.agent_entry(limited)["options"] == { + "method": "sync", + "max_delete": 100, + "max_delete_size": "10G", + } def fields(): @@ -55,8 +69,10 @@ def fields(): ("method", "choice", "archive"), ("exclude", "list", []), ("one_file_system", "bool", False), + ("max_delete", "text", ""), + ("max_delete_size", "text", ""), ] - assert FILES.option_fields()[0].choices == ("archive", "snapshot", "sync") + assert FILES.option_fields()[0].choices == ("archive", "snapshot", "sync", "copy") def from_existing(): @@ -112,3 +128,40 @@ def exclude_rejects_brace_alternation(): def mount_for(path, mount): spec = DatabaseSpec(id="1", engine="files", name="n", path=path) assert FilesEngine.mount_for(spec) == mount + + +def delete_limits_are_written_only_when_set(): + options = FORM.collect( + FILES.option_fields(), + {"method": "sync", "max_delete": " 0 ", "max_delete_size": "1.5g"}, + ) + assert (options["max_delete"], options["max_delete_size"]) == (0, "1.5g") + + unset = FORM.collect(FILES.option_fields(), {"method": "sync"}) + spec = FILES.from_existing({"path": "/srv"}).with_options(unset) + assert FILES.agent_entry(spec)["options"] == {"method": "sync"} + + +@pytest.mark.parametrize("method", ["archive", "snapshot", "copy"]) +def delete_limits_only_apply_to_sync(method): + options = FORM.collect(FILES.option_fields(), {"method": method}) + assert "max_delete" not in options + assert "max_delete_size" not in options + with pytest.raises(ValidationError, match="--max-delete does not apply"): + FORM.collect(FILES.option_fields(), {"method": method, "max_delete": "5"}) + + +@pytest.mark.parametrize( + ("option", "value"), + [ + ("max_delete", "-1"), + ("max_delete", "1.5"), + ("max_delete", "ten"), + ("max_delete_size", "10GB"), + ("max_delete_size", "G"), + ("max_delete_size", "1,5G"), + ], +) +def delete_limits_reject_invalid_values(option, value): + with pytest.raises(ValidationError): + FORM.collect(FILES.option_fields(), {"method": "sync", option: value}) diff --git a/ui/form.py b/ui/form.py index 7fe5d43..8e57b1f 100644 --- a/ui/form.py +++ b/ui/form.py @@ -48,7 +48,16 @@ def ask(self, field: Field, value: Any | None = None) -> Any: def collect( self, fields: Sequence[Field], values: dict[str, Any] ) -> dict[str, Any]: - return {field.name: self.ask(field, values.get(field.name)) for field in fields} + answers: dict[str, Any] = {} + for field in fields: + value = values.get(field.name) + if field.when is None or field.when(answers): + answers[field.name] = self.ask(field, value) + elif value is not None: + raise ValidationError( + f"{field.flag} does not apply here.", hint=field.help + ) + return answers def text( self, prompt: str, *, value=None, default=None, validator=None, name="value" diff --git a/uv.lock b/uv.lock index 4c64ae2..ae5a925 100644 --- a/uv.lock +++ b/uv.lock @@ -92,11 +92,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082 }, ] [[package]] @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "portabase-cli" -version = "26.10.0" +version = "26.10.2" source = { virtual = "." } dependencies = [ { name = "jinja2" },