restic-backups 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.3
2
+ Name: restic-backups
3
+ Version: 0.1.1
4
+ Summary: Restic backup CLI with optional SOPS configuration.
5
+ Requires-Dist: click>=8.1
6
+ Requires-Dist: mlx-whisper>=0.4.0 ; platform_machine == 'arm64' and sys_platform == 'darwin'
7
+ Requires-Dist: pyannote-audio>=3.1
8
+ Requires-Dist: pyyaml>=6.0.3
9
+ Requires-Dist: questionary>=2.1.1
10
+ Requires-Dist: requests>=2.32
11
+ Requires-Dist: textual>=0.80
12
+ Requires-Dist: torch>=2.0
13
+ Requires-Dist: torchaudio>=2.0
14
+ Requires-Dist: typer>=0.25.1
15
+ Requires-Python: >=3.11
16
+ Project-URL: Documentation, https://jr200-labs.github.io/restic-backups/
17
+ Project-URL: Repository, https://github.com/jr200-labs/restic-backups
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Restic Backups
21
+
22
+ YAML configuration and a Python CLI for running multiple restic repositories,
23
+ with optional SOPS decryption. The package currently includes a complete macOS
24
+ Voice Memos workflow for backup, transcription, summarisation, and speaker
25
+ diarization.
26
+
27
+ Backup payloads, generated metadata, model caches, and restores are excluded
28
+ from Git by a default-deny `.gitignore`.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ make install-deps # Homebrew: restic, sops, uv, ffmpeg, jq, coreutils
34
+ make install # uv sync, including the dev group and Quarto
35
+ ```
36
+
37
+ `make init` loads the selected configuration, deduplicates enabled
38
+ stores referenced by a backup, and runs `restic init` once per repository. It
39
+ does not back up files and is unnecessary for an existing repository.
40
+
41
+ ## CLI
42
+
43
+ Use the built-in help for available commands and options:
44
+
45
+ ```sh
46
+ uv run restic-backups --help
47
+ uv run restic-backups generic --help
48
+ uv run restic-backups voice-memos --help
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ Pass a plain YAML file explicitly:
54
+
55
+ ```sh
56
+ uv run restic-backups --config config.yaml check-config
57
+ ```
58
+
59
+ For SOPS, add `--sops`. The equivalent environment variables are
60
+ `RESTIC_BACKUPS_CONFIG` and `RESTIC_BACKUPS_SOPS=1`; they also configure
61
+ `make config-check` and `make init`.
62
+
63
+ The configuration separates:
64
+
65
+ - `credentials`: reusable S3-compatible authentication;
66
+ - `restic-stores`: endpoint, region, bucket, key prefix/password, and optional
67
+ archive policy;
68
+ - `backups`: jobs linked to a store by `restic-store-id`.
69
+
70
+ One credential may serve many stores, and multiple backups may share a store.
71
+ Disabled stores may contain `CHANGE_ME`; all placeholders must be replaced
72
+ before enabling one.
73
+
74
+ ## Data and source paths
75
+
76
+ Managed local artifacts use:
77
+
78
+ ```text
79
+ data/<store-id>/<bucket>/<key-prefix>/<backup-id>/
80
+ ```
81
+
82
+ This is metadata/workspace organization, not a restriction on backup sources.
83
+ Restic may back up absolute paths anywhere on the machine. Resolve a managed
84
+ directory without exposing credentials with:
85
+
86
+ ```sh
87
+ uv run restic-backups generic data-dir voice-memos
88
+ ```
89
+
90
+ ## AWS Glacier
91
+
92
+ Use `GLACIER_IR` with `restore: null` for normal immediate restic access. Cold
93
+ `GLACIER` and `DEEP_ARCHIVE` stores require a configured retrieval tier, days,
94
+ and timeout. Retrieval must also be acknowledged at runtime:
95
+
96
+ ```sh
97
+ ALLOW_ARCHIVE_RETRIEVAL=1 uv run restic-backups generic run \
98
+ --backup <backup-id> restore latest --target <dir>
99
+ ```
100
+
101
+ Storage-class changes apply only to new objects. Use a new `key_prefix` instead
102
+ of mixing storage policies in one repository.
103
+
104
+ ## Documentation
105
+
106
+ ```sh
107
+ make docs # render docs/_site
108
+ make docs-preview # local preview server
109
+ ```
110
+
111
+ Start with [the Quarto guide](docs/index.qmd). Never commit decrypted SOPS
112
+ configuration or anything below `data/`.
@@ -0,0 +1,93 @@
1
+ # Restic Backups
2
+
3
+ YAML configuration and a Python CLI for running multiple restic repositories,
4
+ with optional SOPS decryption. The package currently includes a complete macOS
5
+ Voice Memos workflow for backup, transcription, summarisation, and speaker
6
+ diarization.
7
+
8
+ Backup payloads, generated metadata, model caches, and restores are excluded
9
+ from Git by a default-deny `.gitignore`.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ make install-deps # Homebrew: restic, sops, uv, ffmpeg, jq, coreutils
15
+ make install # uv sync, including the dev group and Quarto
16
+ ```
17
+
18
+ `make init` loads the selected configuration, deduplicates enabled
19
+ stores referenced by a backup, and runs `restic init` once per repository. It
20
+ does not back up files and is unnecessary for an existing repository.
21
+
22
+ ## CLI
23
+
24
+ Use the built-in help for available commands and options:
25
+
26
+ ```sh
27
+ uv run restic-backups --help
28
+ uv run restic-backups generic --help
29
+ uv run restic-backups voice-memos --help
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Pass a plain YAML file explicitly:
35
+
36
+ ```sh
37
+ uv run restic-backups --config config.yaml check-config
38
+ ```
39
+
40
+ For SOPS, add `--sops`. The equivalent environment variables are
41
+ `RESTIC_BACKUPS_CONFIG` and `RESTIC_BACKUPS_SOPS=1`; they also configure
42
+ `make config-check` and `make init`.
43
+
44
+ The configuration separates:
45
+
46
+ - `credentials`: reusable S3-compatible authentication;
47
+ - `restic-stores`: endpoint, region, bucket, key prefix/password, and optional
48
+ archive policy;
49
+ - `backups`: jobs linked to a store by `restic-store-id`.
50
+
51
+ One credential may serve many stores, and multiple backups may share a store.
52
+ Disabled stores may contain `CHANGE_ME`; all placeholders must be replaced
53
+ before enabling one.
54
+
55
+ ## Data and source paths
56
+
57
+ Managed local artifacts use:
58
+
59
+ ```text
60
+ data/<store-id>/<bucket>/<key-prefix>/<backup-id>/
61
+ ```
62
+
63
+ This is metadata/workspace organization, not a restriction on backup sources.
64
+ Restic may back up absolute paths anywhere on the machine. Resolve a managed
65
+ directory without exposing credentials with:
66
+
67
+ ```sh
68
+ uv run restic-backups generic data-dir voice-memos
69
+ ```
70
+
71
+ ## AWS Glacier
72
+
73
+ Use `GLACIER_IR` with `restore: null` for normal immediate restic access. Cold
74
+ `GLACIER` and `DEEP_ARCHIVE` stores require a configured retrieval tier, days,
75
+ and timeout. Retrieval must also be acknowledged at runtime:
76
+
77
+ ```sh
78
+ ALLOW_ARCHIVE_RETRIEVAL=1 uv run restic-backups generic run \
79
+ --backup <backup-id> restore latest --target <dir>
80
+ ```
81
+
82
+ Storage-class changes apply only to new objects. Use a new `key_prefix` instead
83
+ of mixing storage policies in one repository.
84
+
85
+ ## Documentation
86
+
87
+ ```sh
88
+ make docs # render docs/_site
89
+ make docs-preview # local preview server
90
+ ```
91
+
92
+ Start with [the Quarto guide](docs/index.qmd). Never commit decrypted SOPS
93
+ configuration or anything below `data/`.
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "restic-backups"
3
+ version = "0.1.1"
4
+ description = "Restic backup CLI with optional SOPS configuration."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "click>=8.1",
9
+ "mlx-whisper>=0.4.0; platform_system == 'Darwin' and platform_machine == 'arm64'",
10
+ "pyannote.audio>=3.1",
11
+ "pyyaml>=6.0.3",
12
+ "questionary>=2.1.1",
13
+ "requests>=2.32",
14
+ "textual>=0.80",
15
+ "torch>=2.0",
16
+ "torchaudio>=2.0",
17
+ "typer>=0.25.1",
18
+ ]
19
+
20
+ [project.urls]
21
+ Documentation = "https://jr200-labs.github.io/restic-backups/"
22
+ Repository = "https://github.com/jr200-labs/restic-backups"
23
+
24
+ [project.scripts]
25
+ restic-backups = "restic_backups.cli:app"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "mypy>=1.19.1",
30
+ "pytest>=9.0.2",
31
+ "quarto-cli>=1.9.38",
32
+ "ruff>=0.14.14",
33
+ "types-pyyaml>=6.0.12.20250915",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.1,<0.13.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.build-backend]
41
+ module-root = "src"
42
+
43
+ [tool.mypy]
44
+ packages = ["restic_backups"]
45
+
46
+ [[tool.mypy.overrides]]
47
+ module = ["restic_backups.voice_memos.*"]
48
+ ignore_errors = true
49
+
50
+ [tool.ruff]
51
+ extend = ".shared/ruff.toml"
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "restic-backups"
3
+ version = "0.1.1"
4
+ description = "Restic backup CLI with optional SOPS configuration."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "click>=8.1",
9
+ "mlx-whisper>=0.4.0; platform_system == 'Darwin' and platform_machine == 'arm64'",
10
+ "pyannote.audio>=3.1",
11
+ "pyyaml>=6.0.3",
12
+ "questionary>=2.1.1",
13
+ "requests>=2.32",
14
+ "textual>=0.80",
15
+ "torch>=2.0",
16
+ "torchaudio>=2.0",
17
+ "typer>=0.25.1",
18
+ ]
19
+
20
+ [project.urls]
21
+ Documentation = "https://jr200-labs.github.io/restic-backups/"
22
+ Repository = "https://github.com/jr200-labs/restic-backups"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=1.19.1",
27
+ "pytest>=9.0.2",
28
+ "quarto-cli>=1.9.38",
29
+ "ruff>=0.14.14",
30
+ "types-pyyaml>=6.0.12.20250915",
31
+ ]
32
+
33
+ [project.scripts]
34
+ restic-backups = "restic_backups.cli:app"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.1,<0.13.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.build-backend]
41
+ module-root = "src"
42
+
43
+ [tool.mypy]
44
+ packages = ["restic_backups"]
45
+
46
+ # ponytail: the ML/Click workflow is dynamically typed; remove as it gains annotations.
47
+ [[tool.mypy.overrides]]
48
+ module = ["restic_backups.voice_memos.*"]
49
+ ignore_errors = true
50
+
51
+ [tool.ruff]
52
+ extend = ".shared/ruff.toml"
@@ -0,0 +1 @@
1
+ """Restic backups CLI."""
@@ -0,0 +1,103 @@
1
+ """Command-line interface for configured restic backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated, Any, NoReturn
8
+
9
+ import typer
10
+
11
+ from . import config as config_module
12
+ from .errors import BackupError
13
+ from .generic import cli as generic_cli
14
+ from .generic import repository
15
+ from .generic import sops as sops_module
16
+
17
+ app = typer.Typer(
18
+ help="Configured backup commands.",
19
+ no_args_is_help=True,
20
+ )
21
+ app.add_typer(generic_cli.app, name="generic")
22
+
23
+
24
+ @app.callback()
25
+ def configure(
26
+ config_file: Annotated[
27
+ Path | None,
28
+ typer.Option(
29
+ "--config",
30
+ envvar=config_module.CONFIG_ENV,
31
+ dir_okay=False,
32
+ help=f"YAML configuration file. Env: {config_module.CONFIG_ENV}.",
33
+ ),
34
+ ] = None,
35
+ use_sops: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--sops",
39
+ envvar=sops_module.SOPS_ENV,
40
+ help=f"Decrypt the configuration with SOPS. Env: {sops_module.SOPS_ENV}.",
41
+ ),
42
+ ] = False,
43
+ ) -> None:
44
+ """Configure storage before running a command."""
45
+ if config_file is not None:
46
+ os.environ[config_module.CONFIG_ENV] = str(config_file)
47
+ os.environ[sops_module.SOPS_ENV] = "1" if use_sops else "0"
48
+
49
+
50
+ def fail(message: str) -> NoReturn:
51
+ typer.echo(f"restic-backups: {message}", err=True)
52
+ raise typer.Exit(1)
53
+
54
+
55
+ def validated() -> tuple[
56
+ dict[str, Any],
57
+ dict[str, dict[str, Any]],
58
+ dict[str, dict[str, Any]],
59
+ dict[str, dict[str, Any]],
60
+ ]:
61
+ try:
62
+ return config_module.load_validated()
63
+ except BackupError as exc:
64
+ fail(str(exc))
65
+
66
+
67
+ @app.command("check-config")
68
+ def check_config_command() -> None:
69
+ """Validate configuration without contacting remote storage."""
70
+ validated()
71
+ typer.echo("config ok")
72
+
73
+
74
+ @app.command(
75
+ "voice-memos",
76
+ context_settings={
77
+ "allow_extra_args": True,
78
+ "ignore_unknown_options": True,
79
+ "help_option_names": [],
80
+ },
81
+ )
82
+ def voice_memos_command(context: typer.Context) -> None:
83
+ """Back up, transcribe, summarise, and diarize macOS Voice Memos."""
84
+ if "SUMMARIES_DIR" not in os.environ and "--help" not in context.args:
85
+ _, credentials, stores, backups = validated()
86
+ try:
87
+ store, _ = repository.resolve("voice-memos", credentials, stores, backups)
88
+ path = repository.data_dir("voice-memos", store) / "summaries"
89
+ except BackupError as exc:
90
+ fail(str(exc))
91
+ os.environ["SUMMARIES_DIR"] = str(path)
92
+
93
+ from .voice_memos.cli import cli
94
+
95
+ cli.main(
96
+ args=list(context.args),
97
+ prog_name="restic-backups voice-memos",
98
+ standalone_mode=True,
99
+ )
100
+
101
+
102
+ if __name__ == "__main__":
103
+ app()
@@ -0,0 +1,152 @@
1
+ """Load and validate backup configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, NoReturn
8
+
9
+ import yaml
10
+
11
+ from .errors import BackupError
12
+ from .generic import sops
13
+
14
+ CONFIG_ENV = "RESTIC_BACKUPS_CONFIG"
15
+ PLACEHOLDER = "CHANGE_ME"
16
+
17
+
18
+ class ConfigError(Exception):
19
+ pass
20
+
21
+
22
+ def fail(message: str) -> NoReturn:
23
+ raise BackupError(message)
24
+
25
+
26
+ def config_path() -> Path:
27
+ value = os.environ.get(CONFIG_ENV)
28
+ if not value:
29
+ fail(f"set --config or {CONFIG_ENV}")
30
+ return Path(value).expanduser()
31
+
32
+
33
+ def load_config(path: Path, use_sops: bool) -> dict[str, Any]:
34
+ if use_sops:
35
+ return sops.decrypt(path)
36
+ try:
37
+ loaded = yaml.safe_load(path.read_text())
38
+ except FileNotFoundError:
39
+ fail(f"config file not found: {path}")
40
+ except OSError as exc:
41
+ fail(f"could not read {path}: {exc}")
42
+ except yaml.YAMLError as exc:
43
+ fail(f"config is not valid YAML: {exc}")
44
+ if not isinstance(loaded, dict):
45
+ fail(f"config in {path} must be a mapping")
46
+ return loaded
47
+
48
+
49
+ def required_text(item: dict[str, Any], field: str, owner: str) -> str:
50
+ value = item.get(field)
51
+ if not isinstance(value, str) or not value:
52
+ raise ConfigError(f"{owner}.{field} must be a non-empty string")
53
+ return value
54
+
55
+
56
+ def indexed(config: dict[str, Any], section: str) -> dict[str, dict[str, Any]]:
57
+ items = config.get(section)
58
+ if not isinstance(items, list) or not items:
59
+ raise ConfigError(f"{section} must be a non-empty list")
60
+ result: dict[str, dict[str, Any]] = {}
61
+ for item in items:
62
+ if not isinstance(item, dict):
63
+ raise ConfigError(f"{section} entries must be mappings")
64
+ item_id = required_text(item, "id", section)
65
+ if item_id in result:
66
+ raise ConfigError(f"duplicate {section} id '{item_id}'")
67
+ result[item_id] = item
68
+ return result
69
+
70
+
71
+ def validate(
72
+ config: dict[str, Any],
73
+ ) -> tuple[
74
+ dict[str, dict[str, Any]],
75
+ dict[str, dict[str, Any]],
76
+ dict[str, dict[str, Any]],
77
+ ]:
78
+ credentials = indexed(config, "credentials")
79
+ stores = indexed(config, "restic-stores")
80
+ backups = indexed(config, "backups")
81
+
82
+ for credential_id, credential in credentials.items():
83
+ for field in ("access-key-id", "secret-access-key"):
84
+ required_text(credential, field, credential_id)
85
+
86
+ for store_id, store in stores.items():
87
+ credential_id = required_text(store, "credentials-id", store_id)
88
+ store_credential = credentials.get(credential_id)
89
+ if store_credential is None:
90
+ raise ConfigError(
91
+ f"{store_id} references unknown credentials '{credential_id}'"
92
+ )
93
+ if not isinstance(store.get("enabled"), bool):
94
+ raise ConfigError(f"{store_id}.enabled must be true or false")
95
+ required = [
96
+ required_text(store, field, store_id)
97
+ for field in ("endpoint", "region", "bucket", "key_prefix", "password")
98
+ ]
99
+ if store["enabled"] and PLACEHOLDER in required:
100
+ raise ConfigError(f"{store_id} is enabled but contains placeholders")
101
+ if store["enabled"] and any(
102
+ store_credential[field] == PLACEHOLDER
103
+ for field in ("access-key-id", "secret-access-key")
104
+ ):
105
+ raise ConfigError(f"{store_id} uses placeholder credentials")
106
+
107
+ archive = store.get("archive")
108
+ if archive is None:
109
+ continue
110
+ if not isinstance(archive, dict):
111
+ raise ConfigError(f"{store_id}.archive must be a mapping")
112
+ storage_class = required_text(archive, "storage-class", f"{store_id}.archive")
113
+ restore = archive.get("restore")
114
+ if storage_class == "GLACIER_IR":
115
+ if restore is not None:
116
+ raise ConfigError(f"{store_id}: GLACIER_IR forbids a restore policy")
117
+ continue
118
+ tiers = {
119
+ "GLACIER": {"Standard", "Bulk", "Expedited"},
120
+ "DEEP_ARCHIVE": {"Standard", "Bulk"},
121
+ }.get(storage_class)
122
+ if tiers is None or not isinstance(restore, dict):
123
+ raise ConfigError(f"{store_id} has an invalid cold-storage policy")
124
+ if restore.get("tier") not in tiers:
125
+ raise ConfigError(f"{store_id} has an invalid retrieval tier")
126
+ if not isinstance(restore.get("days"), int) or restore["days"] <= 0:
127
+ raise ConfigError(f"{store_id}.archive.restore.days must be positive")
128
+ required_text(restore, "timeout", f"{store_id}.archive.restore")
129
+
130
+ for backup_id, backup in backups.items():
131
+ store_id = required_text(backup, "restic-store-id", backup_id)
132
+ if store_id not in stores:
133
+ raise ConfigError(
134
+ f"{backup_id} references unknown restic store '{store_id}'"
135
+ )
136
+
137
+ return credentials, stores, backups
138
+
139
+
140
+ def load_validated() -> tuple[
141
+ dict[str, Any],
142
+ dict[str, dict[str, Any]],
143
+ dict[str, dict[str, Any]],
144
+ dict[str, dict[str, Any]],
145
+ ]:
146
+ path = config_path()
147
+ loaded = load_config(path, os.environ.get(sops.SOPS_ENV) == "1")
148
+ try:
149
+ credentials, stores, backups = validate(loaded)
150
+ except ConfigError as exc:
151
+ fail(f"invalid config in {path}: {exc}")
152
+ return loaded, credentials, stores, backups
@@ -0,0 +1,5 @@
1
+ """Shared application errors."""
2
+
3
+
4
+ class BackupError(Exception):
5
+ pass
@@ -0,0 +1 @@
1
+ """Generic restic repository commands."""