dycw-restic 0.2.22__py3-none-any.whl

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,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: dycw-restic
3
+ Version: 0.2.22
4
+ Summary: Library to operate `restic`
5
+ Author: Derek Wan
6
+ Author-email: Derek Wan <d.wan@icloud.com>
7
+ Requires-Dist: click>=8.3.1,<9
8
+ Requires-Dist: dycw-utilities>=0.172.5,<1
9
+ Requires-Dist: typed-settings[attrs,click]>=25.3.0,<26
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+ # `restic`
14
+
15
+ Library to operate `restic`
@@ -0,0 +1,15 @@
1
+ restic/__init__.py,sha256=yhMJ-0LPYMf--j6qVDnbLfF1nV_AHSSJISPpIr9pFm8,59
2
+ restic/cli.py,sha256=YVrgIwCkAMI9BmbjAC1M4aFZ2WjNk1f8YqfauwfLwG8,5432
3
+ restic/click.py,sha256=cuqKKXlErXsb_vNkWHKyo_xnrTgo8qhscaEWqsT7Nl8,1525
4
+ restic/constants.py,sha256=uu2dXIlx3vyMNgJUs1lrCMOYBPGGUviVJ5qvxqOx6Tw,911
5
+ restic/lib.py,sha256=-d3dPReAJikNdLWHNC7QjJiAHYG6vj435KTCc0A09Xc,10454
6
+ restic/logging.py,sha256=tzoiz1F2T-AavxahNduXxfnFvhmcBh6JHskDgUhcNVU,119
7
+ restic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ restic/repo.py,sha256=uDOEzkSWz0vJR-SFFraYtVSD-bl7C6FWlPtfAdDy9KI,3544
9
+ restic/settings.py,sha256=a2WPOeB_VwvbZJ-uxuJpJB1RE5TH1S-BD0IeIIf1v_8,12988
10
+ restic/types.py,sha256=lCchCT7Y7yo0PMBGHgYjpuTDf9z2orPjKiPuje1vfmE,229
11
+ restic/utilities.py,sha256=uXq5Jl_FFjVNCLYG9si4KNiiCJWG2nk0Yr49c4TgA1Q,3215
12
+ dycw_restic-0.2.22.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
13
+ dycw_restic-0.2.22.dist-info/entry_points.txt,sha256=6byn-7KAIlNAdTjaNQ3DjylzzazsZB8SLFvvrqv641o,48
14
+ dycw_restic-0.2.22.dist-info/METADATA,sha256=PHpIDO2qcLXFznHv-ujYWYzW6byripPVLxTqOVyjzfA,387
15
+ dycw_restic-0.2.22.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ py-restic = restic.cli:_main
3
+
restic/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.2.22"
restic/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ import click
7
+ from click import argument, group
8
+ from typed_settings import click_options
9
+ from utilities.click import CONTEXT_SETTINGS
10
+ from utilities.logging import basic_config
11
+ from utilities.os import is_pytest
12
+
13
+ import restic.click
14
+ import restic.repo
15
+ from restic.lib import backup, copy, forget, init, restore, snapshots
16
+ from restic.logging import LOGGER
17
+ from restic.settings import (
18
+ LOADERS,
19
+ BackupSettings,
20
+ CopySettings,
21
+ ForgetSettings,
22
+ InitSettings,
23
+ RestoreSettings,
24
+ SnapshotsSettings,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from utilities.types import PathLike
29
+
30
+
31
+ @group(**CONTEXT_SETTINGS)
32
+ def _main() -> None: ...
33
+
34
+
35
+ @_main.command(name="init", **CONTEXT_SETTINGS)
36
+ @argument("repo", type=restic.click.Repo())
37
+ @click_options(InitSettings, LOADERS, show_envvars_in_help=True)
38
+ def init_sub_cmd(settings: InitSettings, /, *, repo: restic.repo.Repo) -> None:
39
+ if is_pytest():
40
+ return
41
+ init(repo, password=settings.password)
42
+
43
+
44
+ @_main.command(name="backup", **CONTEXT_SETTINGS)
45
+ @argument("path", type=click.Path(path_type=Path))
46
+ @argument("repo", type=restic.click.Repo())
47
+ @click_options(BackupSettings, LOADERS, show_envvars_in_help=True)
48
+ def backup_sub_cmd(
49
+ settings: BackupSettings, /, *, path: PathLike, repo: restic.repo.Repo
50
+ ) -> None:
51
+ if is_pytest():
52
+ return
53
+ backup(
54
+ path,
55
+ repo,
56
+ chmod=settings.chmod,
57
+ chown=settings.chown,
58
+ password=settings.password,
59
+ dry_run=settings.dry_run,
60
+ exclude=settings.exclude,
61
+ exclude_i=settings.exclude_i,
62
+ read_concurrency=settings.read_concurrency,
63
+ tag_backup=settings.tag_backup,
64
+ run_forget=settings.run_forget,
65
+ keep_last=settings.keep_last,
66
+ keep_hourly=settings.keep_hourly,
67
+ keep_daily=settings.keep_daily,
68
+ keep_weekly=settings.keep_weekly,
69
+ keep_monthly=settings.keep_monthly,
70
+ keep_yearly=settings.keep_yearly,
71
+ keep_within=settings.keep_within,
72
+ keep_within_hourly=settings.keep_within_hourly,
73
+ keep_within_daily=settings.keep_within_daily,
74
+ keep_within_weekly=settings.keep_within_weekly,
75
+ keep_within_monthly=settings.keep_within_monthly,
76
+ keep_within_yearly=settings.keep_within_yearly,
77
+ prune=settings.prune,
78
+ repack_cacheable_only=settings.repack_cacheable_only,
79
+ repack_small=settings.repack_small,
80
+ repack_uncompressed=settings.repack_uncompressed,
81
+ tag_forget=settings.tag_forget,
82
+ sleep=settings.sleep,
83
+ )
84
+
85
+
86
+ @_main.command(name="copy", **CONTEXT_SETTINGS)
87
+ @argument("src", type=restic.click.Repo())
88
+ @argument("dest", type=restic.click.Repo())
89
+ @click_options(CopySettings, LOADERS, show_envvars_in_help=True)
90
+ def copy_sub_cmd(
91
+ settings: CopySettings, /, *, src: restic.repo.Repo, dest: restic.repo.Repo
92
+ ) -> None:
93
+ if is_pytest():
94
+ return
95
+ copy(
96
+ src,
97
+ dest,
98
+ src_password=settings.src_password,
99
+ dest_password=settings.dest_password,
100
+ tag=settings.tag,
101
+ )
102
+
103
+
104
+ @_main.command(name="forget", **CONTEXT_SETTINGS)
105
+ @argument("repo", type=restic.click.Repo())
106
+ @click_options(ForgetSettings, LOADERS, show_envvars_in_help=True)
107
+ def forget_sub_cmd(settings: ForgetSettings, /, *, repo: restic.repo.Repo) -> None:
108
+ if is_pytest():
109
+ return
110
+ forget(
111
+ repo,
112
+ password=settings.password,
113
+ dry_run=settings.dry_run,
114
+ keep_last=settings.keep_last,
115
+ keep_hourly=settings.keep_hourly,
116
+ keep_daily=settings.keep_daily,
117
+ keep_weekly=settings.keep_weekly,
118
+ keep_monthly=settings.keep_monthly,
119
+ keep_yearly=settings.keep_yearly,
120
+ keep_within=settings.keep_within,
121
+ keep_within_hourly=settings.keep_within_hourly,
122
+ keep_within_daily=settings.keep_within_daily,
123
+ keep_within_weekly=settings.keep_within_weekly,
124
+ keep_within_monthly=settings.keep_within_monthly,
125
+ keep_within_yearly=settings.keep_within_yearly,
126
+ prune=settings.prune,
127
+ repack_cacheable_only=settings.repack_cacheable_only,
128
+ repack_small=settings.repack_small,
129
+ repack_uncompressed=settings.repack_uncompressed,
130
+ tag=settings.tag,
131
+ )
132
+
133
+
134
+ @_main.command(name="restore", **CONTEXT_SETTINGS)
135
+ @argument("repo", type=restic.click.Repo())
136
+ @argument("target", type=click.Path(path_type=Path))
137
+ @click_options(RestoreSettings, LOADERS, show_envvars_in_help=True)
138
+ def restore_sub_cmd(
139
+ settings: RestoreSettings, /, *, repo: restic.repo.Repo, target: PathLike
140
+ ) -> None:
141
+ if is_pytest():
142
+ return
143
+ restore(
144
+ repo,
145
+ target,
146
+ password=settings.password,
147
+ delete=settings.delete,
148
+ dry_run=settings.dry_run,
149
+ exclude=settings.exclude,
150
+ exclude_i=settings.exclude_i,
151
+ include=settings.include,
152
+ include_i=settings.include_i,
153
+ tag=settings.tag,
154
+ snapshot=settings.snapshot,
155
+ )
156
+
157
+
158
+ @_main.command(name="snapshots", **CONTEXT_SETTINGS)
159
+ @argument("repo", type=restic.click.Repo())
160
+ @click_options(SnapshotsSettings, LOADERS, show_envvars_in_help=True)
161
+ def snapshots_sub_cmd(
162
+ settings: SnapshotsSettings, /, *, repo: restic.repo.Repo
163
+ ) -> None:
164
+ if is_pytest():
165
+ return
166
+ snapshots(repo, password=settings.password)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ basic_config(obj=LOGGER)
171
+ _main()
restic/click.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import suppress
4
+ from pathlib import Path
5
+ from re import search
6
+ from typing import TYPE_CHECKING, assert_never, override
7
+
8
+ from click import Context, Parameter, ParamType
9
+ from utilities.re import ExtractGroupError, ExtractGroupsError
10
+
11
+ from restic.repo import SFTP, Backblaze, Local
12
+
13
+ if TYPE_CHECKING:
14
+ import restic.repo
15
+
16
+
17
+ class Repo(ParamType):
18
+ name = "repo"
19
+
20
+ @override
21
+ def __repr__(self) -> str:
22
+ return self.name.upper()
23
+
24
+ @override
25
+ def convert(
26
+ self,
27
+ value: restic.repo.Repo | str,
28
+ param: Parameter | None,
29
+ ctx: Context | None,
30
+ ) -> restic.repo.Repo:
31
+ match value:
32
+ case Backblaze() | Local() | SFTP():
33
+ return value
34
+ case str():
35
+ try:
36
+ return Backblaze.parse(value)
37
+ except ValueError, ExtractGroupsError:
38
+ if search("b2", value):
39
+ message = f"For a Backblaze repository {value!r}, the environment varaibles 'BACKBLAZE_KEY_ID' and 'BACKBLAZE_APPLICATION_KEY' must be defined"
40
+ return self.fail(message, param, ctx)
41
+ with suppress(ExtractGroupsError):
42
+ return SFTP.parse(value)
43
+ try:
44
+ return Local.parse(value)
45
+ except ExtractGroupError:
46
+ return Local(Path(value))
47
+ case never:
48
+ assert_never(never)
49
+
50
+
51
+ __all__ = ["Repo"]
restic/constants.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import NotRequired, TypedDict
4
+
5
+
6
+ class KeepKwargs(TypedDict):
7
+ keep_last: NotRequired[int]
8
+ keep_hourly: NotRequired[int]
9
+ keep_daily: NotRequired[int]
10
+ keep_weekly: NotRequired[int]
11
+ keep_monthly: NotRequired[int]
12
+ keep_yearly: NotRequired[int]
13
+ keep_within: NotRequired[str]
14
+ keep_within_hourly: NotRequired[str]
15
+ keep_within_daily: NotRequired[str]
16
+ keep_within_weekly: NotRequired[str]
17
+ keep_within_monthly: NotRequired[str]
18
+ keep_within_yearly: NotRequired[str]
19
+
20
+
21
+ DEFAULT_KEEP_KWARGS = KeepKwargs(
22
+ keep_last=100,
23
+ keep_hourly=24 * 7,
24
+ keep_daily=30,
25
+ keep_weekly=52,
26
+ keep_monthly=5 * 12,
27
+ keep_yearly=10,
28
+ keep_within_hourly="7d",
29
+ keep_within_daily="1m",
30
+ keep_within_weekly="1y",
31
+ keep_within_monthly="5y",
32
+ keep_within_yearly="10y",
33
+ )
34
+
35
+
36
+ __all__ = ["DEFAULT_KEEP_KWARGS", "KeepKwargs"]
restic/lib.py ADDED
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from re import MULTILINE, search
5
+ from subprocess import CalledProcessError
6
+ from typing import TYPE_CHECKING
7
+
8
+ from utilities.subprocess import run
9
+ from whenever import TimeDelta
10
+
11
+ from restic.logging import LOGGER
12
+ from restic.repo import yield_repo_env
13
+ from restic.settings import SETTINGS
14
+ from restic.utilities import (
15
+ expand_bool,
16
+ expand_dry_run,
17
+ expand_exclude,
18
+ expand_exclude_i,
19
+ expand_include,
20
+ expand_include_i,
21
+ expand_keep,
22
+ expand_keep_within,
23
+ expand_tag,
24
+ run_chmod,
25
+ yield_password,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from utilities.types import PathLike
30
+
31
+ from restic.repo import Repo
32
+ from restic.types import PasswordLike
33
+
34
+
35
+ def backup(
36
+ path: PathLike,
37
+ repo: Repo,
38
+ /,
39
+ *,
40
+ chmod: bool = SETTINGS.chmod,
41
+ chown: str | None = SETTINGS.chown,
42
+ password: PasswordLike = SETTINGS.password,
43
+ dry_run: bool = SETTINGS.dry_run,
44
+ exclude: list[str] | None = SETTINGS.exclude_backup,
45
+ exclude_i: list[str] | None = SETTINGS.exclude_i_backup,
46
+ read_concurrency: int = SETTINGS.read_concurrency,
47
+ tag_backup: list[str] | None = SETTINGS.tag_backup,
48
+ run_forget: bool = SETTINGS.run_forget,
49
+ keep_last: int | None = SETTINGS.keep_last,
50
+ keep_hourly: int | None = SETTINGS.keep_hourly,
51
+ keep_daily: int | None = SETTINGS.keep_daily,
52
+ keep_weekly: int | None = SETTINGS.keep_weekly,
53
+ keep_monthly: int | None = SETTINGS.keep_monthly,
54
+ keep_yearly: int | None = SETTINGS.keep_yearly,
55
+ keep_within: str | None = SETTINGS.keep_within,
56
+ keep_within_hourly: str | None = SETTINGS.keep_within_hourly,
57
+ keep_within_daily: str | None = SETTINGS.keep_within_daily,
58
+ keep_within_weekly: str | None = SETTINGS.keep_within_weekly,
59
+ keep_within_monthly: str | None = SETTINGS.keep_within_monthly,
60
+ keep_within_yearly: str | None = SETTINGS.keep_within_yearly,
61
+ prune: bool = SETTINGS.prune,
62
+ repack_cacheable_only: bool = SETTINGS.repack_cacheable_only,
63
+ repack_small: bool = SETTINGS.repack_small,
64
+ repack_uncompressed: bool = SETTINGS.repack_uncompressed,
65
+ tag_forget: list[str] | None = SETTINGS.tag_forget,
66
+ sleep: int | None = SETTINGS.sleep,
67
+ ) -> None:
68
+ LOGGER.info("Backing up '%s' to '%s'...", path, repo)
69
+ if chmod:
70
+ run_chmod(path, "d", "u=rwx,g=rx,o=rx")
71
+ run_chmod(path, "f", "u=rw,g=r,o=r")
72
+ if chown is not None:
73
+ run("sudo", "chown", "-R", f"{chown}:{chown}", str(path))
74
+ try:
75
+ _backup_core(
76
+ path,
77
+ repo,
78
+ password=password,
79
+ dry_run=dry_run,
80
+ exclude=exclude,
81
+ exclude_i=exclude_i,
82
+ read_concurrency=read_concurrency,
83
+ tag=tag_backup,
84
+ )
85
+ except CalledProcessError as error:
86
+ if search(
87
+ "Is there a repository at the following location?",
88
+ error.stderr,
89
+ flags=MULTILINE,
90
+ ):
91
+ LOGGER.info("Auto-initializing repo...")
92
+ init(repo, password=password)
93
+ _backup_core(
94
+ path,
95
+ repo,
96
+ password=password,
97
+ dry_run=dry_run,
98
+ exclude=exclude,
99
+ exclude_i=exclude_i,
100
+ read_concurrency=read_concurrency,
101
+ tag=tag_backup,
102
+ )
103
+ else:
104
+ raise
105
+ if run_forget:
106
+ forget(
107
+ repo,
108
+ password=password,
109
+ keep_last=keep_last,
110
+ keep_hourly=keep_hourly,
111
+ keep_daily=keep_daily,
112
+ keep_weekly=keep_weekly,
113
+ keep_monthly=keep_monthly,
114
+ keep_yearly=keep_yearly,
115
+ keep_within=keep_within,
116
+ keep_within_hourly=keep_within_hourly,
117
+ keep_within_daily=keep_within_daily,
118
+ keep_within_weekly=keep_within_weekly,
119
+ keep_within_monthly=keep_within_monthly,
120
+ keep_within_yearly=keep_within_yearly,
121
+ prune=prune,
122
+ repack_cacheable_only=repack_cacheable_only,
123
+ repack_small=repack_small,
124
+ repack_uncompressed=repack_uncompressed,
125
+ tag=tag_forget,
126
+ )
127
+ if sleep is None:
128
+ LOGGER.info("Finished backing up '%s' to '%s'", path, repo)
129
+ else:
130
+ delta = TimeDelta(seconds=sleep)
131
+ LOGGER.info(
132
+ "Finished backing up '%s' to '%s'; sleeping for %s...", path, repo, delta
133
+ )
134
+ time.sleep(sleep)
135
+ LOGGER.info("Finishing sleeping for %s", delta)
136
+
137
+
138
+ def _backup_core(
139
+ path: PathLike,
140
+ repo: Repo,
141
+ /,
142
+ *,
143
+ password: PasswordLike = SETTINGS.password,
144
+ dry_run: bool = SETTINGS.dry_run,
145
+ exclude: list[str] | None = SETTINGS.exclude_backup,
146
+ exclude_i: list[str] | None = SETTINGS.exclude_i_backup,
147
+ read_concurrency: int = SETTINGS.read_concurrency,
148
+ tag: list[str] | None = SETTINGS.tag_backup,
149
+ ) -> None:
150
+ with yield_repo_env(repo), yield_password(password=password):
151
+ run(
152
+ "restic",
153
+ "backup",
154
+ *expand_dry_run(dry_run=dry_run),
155
+ *expand_exclude(exclude=exclude),
156
+ *expand_exclude_i(exclude_i=exclude_i),
157
+ "--read-concurrency",
158
+ str(read_concurrency),
159
+ *expand_tag(tag=tag),
160
+ str(path),
161
+ print=True,
162
+ )
163
+
164
+
165
+ def init(repo: Repo, /, *, password: PasswordLike = SETTINGS.password) -> None:
166
+ LOGGER.info("Initializing '%s'", repo)
167
+ with yield_repo_env(repo), yield_password(password=password):
168
+ run("restic", "init", print=True)
169
+ LOGGER.info("Finished initializing '%s'", repo)
170
+
171
+
172
+ def copy(
173
+ src: Repo,
174
+ dest: Repo,
175
+ /,
176
+ *,
177
+ src_password: PasswordLike = SETTINGS.password,
178
+ dest_password: PasswordLike = SETTINGS.password,
179
+ tag: list[str] | None = SETTINGS.tag_copy,
180
+ ) -> None:
181
+ LOGGER.info("Copying snapshots from '%s' to '%s'...", src, dest)
182
+ with (
183
+ yield_repo_env(src, env_var="RESTIC_FROM_REPOSITORY"),
184
+ yield_repo_env(dest),
185
+ yield_password(password=src_password, env_var="RESTIC_FROM_PASSWORD_FILE"),
186
+ yield_password(password=dest_password),
187
+ ):
188
+ run("restic", "copy", *expand_tag(tag=tag), print=True)
189
+ LOGGER.info("Finished copying snapshots from '%s' to '%s'", src, dest)
190
+
191
+
192
+ def forget(
193
+ repo: Repo,
194
+ /,
195
+ *,
196
+ password: PasswordLike = SETTINGS.password,
197
+ dry_run: bool = SETTINGS.dry_run,
198
+ keep_last: int | None = SETTINGS.keep_last,
199
+ keep_hourly: int | None = SETTINGS.keep_hourly,
200
+ keep_daily: int | None = SETTINGS.keep_daily,
201
+ keep_weekly: int | None = SETTINGS.keep_weekly,
202
+ keep_monthly: int | None = SETTINGS.keep_monthly,
203
+ keep_yearly: int | None = SETTINGS.keep_yearly,
204
+ keep_within: str | None = SETTINGS.keep_within,
205
+ keep_within_hourly: str | None = SETTINGS.keep_within_hourly,
206
+ keep_within_daily: str | None = SETTINGS.keep_within_daily,
207
+ keep_within_weekly: str | None = SETTINGS.keep_within_weekly,
208
+ keep_within_monthly: str | None = SETTINGS.keep_within_monthly,
209
+ keep_within_yearly: str | None = SETTINGS.keep_within_yearly,
210
+ prune: bool = SETTINGS.prune,
211
+ repack_cacheable_only: bool = SETTINGS.repack_cacheable_only,
212
+ repack_small: bool = SETTINGS.repack_small,
213
+ repack_uncompressed: bool = SETTINGS.repack_uncompressed,
214
+ tag: list[str] | None = SETTINGS.tag_forget,
215
+ ) -> None:
216
+ LOGGER.info("Forgetting snapshots in '%s'...", repo)
217
+ with yield_repo_env(repo), yield_password(password=password):
218
+ run(
219
+ "restic",
220
+ "forget",
221
+ *expand_dry_run(dry_run=dry_run),
222
+ *expand_keep("last", n=keep_last),
223
+ *expand_keep("hourly", n=keep_hourly),
224
+ *expand_keep("daily", n=keep_daily),
225
+ *expand_keep("weekly", n=keep_weekly),
226
+ *expand_keep("monthly", n=keep_monthly),
227
+ *expand_keep("yearly", n=keep_yearly),
228
+ *expand_keep_within("within", duration=keep_within),
229
+ *expand_keep_within("within-hourly", duration=keep_within_hourly),
230
+ *expand_keep_within("within-daily", duration=keep_within_daily),
231
+ *expand_keep_within("within-weekly", duration=keep_within_weekly),
232
+ *expand_keep_within("within-monthly", duration=keep_within_monthly),
233
+ *expand_keep_within("within-yearly", duration=keep_within_yearly),
234
+ *expand_bool("prune", bool_=prune),
235
+ *expand_bool("repack-cacheable-only", bool_=repack_cacheable_only),
236
+ *expand_bool("repack-small", bool_=repack_small),
237
+ *expand_bool("repack-uncompressed", bool_=repack_uncompressed),
238
+ *expand_tag(tag=tag),
239
+ print=True,
240
+ )
241
+ LOGGER.info("Finished forgetting snapshots in '%s'", repo)
242
+
243
+
244
+ def restore(
245
+ repo: Repo,
246
+ target: PathLike,
247
+ /,
248
+ *,
249
+ password: PasswordLike = SETTINGS.password,
250
+ delete: bool = SETTINGS.delete,
251
+ dry_run: bool = SETTINGS.dry_run,
252
+ exclude: list[str] | None = SETTINGS.exclude_restore,
253
+ exclude_i: list[str] | None = SETTINGS.exclude_i_restore,
254
+ include: list[str] | None = SETTINGS.include_restore,
255
+ include_i: list[str] | None = SETTINGS.include_i_restore,
256
+ tag: list[str] | None = SETTINGS.tag_restore,
257
+ snapshot: str = SETTINGS.snapshot,
258
+ ) -> None:
259
+ LOGGER.info("Restoring snapshot '%s' of '%s' to '%s'...", snapshot, repo, target)
260
+ with yield_repo_env(repo), yield_password(password=password):
261
+ run(
262
+ "restic",
263
+ "restore",
264
+ *expand_bool("delete", bool_=delete),
265
+ *expand_dry_run(dry_run=dry_run),
266
+ *expand_exclude(exclude=exclude),
267
+ *expand_exclude_i(exclude_i=exclude_i),
268
+ *expand_include(include=include),
269
+ *expand_include_i(include_i=include_i),
270
+ *expand_tag(tag=tag),
271
+ "--target",
272
+ str(target),
273
+ "--verify",
274
+ snapshot,
275
+ print=True,
276
+ )
277
+ LOGGER.info(
278
+ "Finished restoring snapshot '%s' of '%s' to '%s'", snapshot, repo, target
279
+ )
280
+
281
+
282
+ def snapshots(repo: Repo, /, *, password: PasswordLike = SETTINGS.password) -> None:
283
+ LOGGER.info("Listing snapshots in '%s'...", repo)
284
+ with yield_repo_env(repo), yield_password(password=password):
285
+ run("restic", "snapshots", print=True)
286
+ LOGGER.info("Finished listing snapshots in '%s'", repo)
287
+
288
+
289
+ __all__ = ["backup", "copy", "forget", "init", "restore", "snapshots"]
restic/logging.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+
5
+ LOGGER = getLogger("restic")
6
+
7
+
8
+ __all__ = ["LOGGER"]
restic/py.typed ADDED
File without changes
restic/repo.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Self, assert_never
7
+
8
+ from typed_settings import Secret, load_settings
9
+ from utilities.os import temp_environ
10
+ from utilities.re import extract_group, extract_groups
11
+
12
+ from restic.settings import LOADERS, SETTINGS, Settings
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterator
16
+
17
+ from restic.types import SecretLike
18
+
19
+
20
+ type Repo = Backblaze | Local | SFTP
21
+
22
+
23
+ @dataclass(order=True, unsafe_hash=True, slots=True)
24
+ class Backblaze:
25
+ key_id: Secret[str]
26
+ application_key: Secret[str]
27
+ bucket: str
28
+ path: Path
29
+
30
+ @classmethod
31
+ def parse(
32
+ cls,
33
+ text: str,
34
+ /,
35
+ *,
36
+ key_id: SecretLike | None = SETTINGS.backblaze_key_id,
37
+ application_key: SecretLike | None = SETTINGS.backblaze_application_key,
38
+ ) -> Self:
39
+ settings = load_settings(Settings, LOADERS)
40
+ match key_id, settings.backblaze_key_id:
41
+ case Secret() as key_id_use, _:
42
+ ...
43
+ case str(), _:
44
+ key_id_use = Secret(key_id)
45
+ case None, Secret() as key_id_use:
46
+ ...
47
+ case None, None:
48
+ msg = "'BACKBLAZE_KEY_ID' is missing"
49
+ raise ValueError(msg)
50
+ case never:
51
+ assert_never(never)
52
+ match application_key, settings.backblaze_application_key:
53
+ case Secret() as application_key_use, _:
54
+ ...
55
+ case str(), _:
56
+ application_key_use = Secret(application_key)
57
+ case None, Secret() as application_key_use:
58
+ ...
59
+ case None, None:
60
+ msg = "'BACKBLAZE_APPLICATION_KEY' is missing"
61
+ raise ValueError(msg)
62
+ case never:
63
+ assert_never(never)
64
+ bucket, path = extract_groups(r"^b2:([^@:]+):([^@+]+)$", text)
65
+ return cls(key_id_use, application_key_use, bucket, Path(path))
66
+
67
+ @property
68
+ def repository(self) -> str:
69
+ return f"b2:{self.bucket}:{self.path}"
70
+
71
+
72
+ @dataclass(order=True, unsafe_hash=True, slots=True)
73
+ class Local:
74
+ path: Path
75
+
76
+ @classmethod
77
+ def parse(cls, text: str, /) -> Self:
78
+ path = extract_group(r"^local:([^@:]+)$", text)
79
+ return cls(Path(path))
80
+
81
+ @property
82
+ def repository(self) -> str:
83
+ return f"local:{self.path}"
84
+
85
+
86
+ @dataclass(order=True, unsafe_hash=True, slots=True)
87
+ class SFTP:
88
+ user: str
89
+ hostname: str
90
+ path: Path
91
+
92
+ @classmethod
93
+ def parse(cls, text: str, /) -> Self:
94
+ user, hostname, path = extract_groups(
95
+ r"^sftp:([^@:]+)@([^@:]+):([^@:]+)$", text
96
+ )
97
+ return cls(user, hostname, Path(path))
98
+
99
+ @property
100
+ def repository(self) -> str:
101
+ return f"sftp:{self.user}@{self.hostname}:{self.path}"
102
+
103
+
104
+ @contextmanager
105
+ def yield_repo_env(
106
+ repo: Repo, /, *, env_var: str = "RESTIC_REPOSITORY"
107
+ ) -> Iterator[None]:
108
+ match repo:
109
+ case Backblaze():
110
+ with temp_environ(
111
+ {env_var: repo.repository},
112
+ B2_ACCOUNT_ID=repo.key_id.get_secret_value(),
113
+ B2_ACCOUNT_KEY=repo.application_key.get_secret_value(),
114
+ ):
115
+ yield
116
+ case Local() | SFTP():
117
+ with temp_environ({env_var: repo.repository}):
118
+ yield
119
+ case never:
120
+ assert_never(never)
121
+
122
+
123
+ __all__ = ["SFTP", "Backblaze", "Local", "Repo", "yield_repo_env"]
restic/settings.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ from os import getenv
4
+ from typing import Any
5
+
6
+ from attrs import fields_dict
7
+ from typed_settings import (
8
+ EnvLoader,
9
+ FileLoader,
10
+ Secret,
11
+ TomlFormat,
12
+ find,
13
+ load_settings,
14
+ option,
15
+ secret,
16
+ settings,
17
+ )
18
+ from utilities.os import CPU_COUNT
19
+
20
+ CONFIG_FILE = getenv("RESTIC_CONFIG_FILE", "config.toml")
21
+ SECRETS_FILE = getenv("RESTIC_SECRETS_FILE", "secrets.toml")
22
+ LOADERS = [
23
+ FileLoader({"*.toml": TomlFormat(None)}, [find(CONFIG_FILE), find(SECRETS_FILE)]),
24
+ EnvLoader(""),
25
+ ]
26
+
27
+
28
+ @settings(kw_only=True)
29
+ class Settings:
30
+ # global
31
+ dry_run: bool = option(default=False, help="Just print what would have been done")
32
+ password: Secret[str] = secret(
33
+ default=Secret("password"), help="Repository password or password file"
34
+ )
35
+ # backblaze
36
+ backblaze_key_id: Secret[str] | None = secret(default=None, help="Backblaze key ID")
37
+ backblaze_application_key: Secret[str] | None = secret(
38
+ default=None, help="Backblaze application key"
39
+ )
40
+ # backup
41
+ chmod: bool = option(default=False, help="Change permissions of the directory/file")
42
+ chown: str | None = option(
43
+ default=None, help="Change ownership of the directory/file"
44
+ )
45
+ exclude_backup: list[str] | None = option(default=None, help="Exclude a pattern")
46
+ exclude_i_backup: list[str] | None = option(
47
+ default=None, help="Exclude a pattern but ignores the casing of filenames"
48
+ )
49
+ read_concurrency: int = option(
50
+ default=max(round(CPU_COUNT / 2), 2), help="Read `n` files concurrency"
51
+ )
52
+ tag_backup: list[str] | None = option(
53
+ default=None, help="Add tags for the snapshot in the format `tag[,tag,...]`"
54
+ )
55
+ run_forget: bool = option(
56
+ default=True, help="Automatically run the 'forget' command"
57
+ )
58
+ sleep: int | None = option(default=None, help="Sleep after a successful backup")
59
+ # copy
60
+ tag_copy: list[str] | None = option(
61
+ default=None, help="Only consider snapshots including `tag[,tag,...]`"
62
+ )
63
+ # forget
64
+ keep_last: int | None = option(default=None, help="Keep the last n snapshots")
65
+ keep_hourly: int | None = option(
66
+ default=None, help="Keep the last n hourly snapshots"
67
+ )
68
+ keep_daily: int | None = option(
69
+ default=None, help="Keep the last n daily snapshots"
70
+ )
71
+ keep_weekly: int | None = option(
72
+ default=None, help="Keep the last n weekly snapshots"
73
+ )
74
+ keep_monthly: int | None = option(
75
+ default=None, help="Keep the last n monthly snapshots"
76
+ )
77
+ keep_yearly: int | None = option(
78
+ default=None, help="Keep the last n yearly snapshots"
79
+ )
80
+ keep_within: str | None = option(
81
+ default=None,
82
+ help="Keep snapshots that are newer than duration relative to the latest snapshot",
83
+ )
84
+ keep_within_hourly: str | None = option(
85
+ default=None,
86
+ help="Keep hourly snapshots that are newer than duration relative to the latest snapshot",
87
+ )
88
+ keep_within_daily: str | None = option(
89
+ default=None,
90
+ help="Keep daily snapshots that are newer than duration relative to the latest snapshot",
91
+ )
92
+ keep_within_weekly: str | None = option(
93
+ default=None,
94
+ help="Keep weekly snapshots that are newer than duration relative to the latest snapshot",
95
+ )
96
+ keep_within_monthly: str | None = option(
97
+ default=None,
98
+ help="Keep monthly snapshots that are newer than duration relative to the latest snapshot",
99
+ )
100
+ keep_within_yearly: str | None = option(
101
+ default=None,
102
+ help="Keep yearly snapshots that are newer than duration relative to the latest snapshot",
103
+ )
104
+ prune: bool = option(
105
+ default=True,
106
+ help="Automatically run the 'prune' command if snapshots have been removed",
107
+ )
108
+ repack_cacheable_only: bool = option(
109
+ default=False, help="Only repack packs which are cacheable"
110
+ )
111
+ repack_small: bool = option(
112
+ default=True, help="Repack pack files below 80% of target pack size"
113
+ )
114
+ repack_uncompressed: bool = option(
115
+ default=True, help="Repack all uncompressed data"
116
+ )
117
+ tag_forget: list[str] | None = option(
118
+ default=None, help="Only consider snapshots including tag[,tag,...]"
119
+ )
120
+ # restore
121
+ delete: bool = option(
122
+ default=False,
123
+ help="Delete files from target directory if they do not exist in snapshot",
124
+ )
125
+ exclude_restore: list[str] | None = option(default=None, help="Exclude a pattern")
126
+ exclude_i_restore: list[str] | None = option(
127
+ default=None, help="Exclude a pattern but ignores the casing of filenames"
128
+ )
129
+ include_restore: list[str] | None = option(default=None, help="Include a pattern")
130
+ include_i_restore: list[str] | None = option(
131
+ default=None, help="Include a pattern but ignores the casing of filenames"
132
+ )
133
+ tag_restore: list[str] | None = option(
134
+ default=None,
135
+ help='Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given',
136
+ )
137
+ snapshot: str = option(default="latest", help="Snapshot ID to restore")
138
+
139
+
140
+ SETTINGS = load_settings(Settings, LOADERS)
141
+
142
+
143
+ def _get_help(member_descriptor: Any, /) -> None:
144
+ return fields_dict(Settings)[member_descriptor.__name__].metadata["typed-settings"][
145
+ "help"
146
+ ]
147
+
148
+
149
+ @settings(kw_only=True)
150
+ class InitSettings:
151
+ password: Secret[str] = secret(
152
+ default=SETTINGS.password, help=_get_help(Settings.password)
153
+ )
154
+
155
+
156
+ @settings(kw_only=True)
157
+ class BackupSettings:
158
+ chmod: bool = option(default=SETTINGS.chmod, help=_get_help(Settings.chmod))
159
+ chown: str | None = option(default=SETTINGS.chown, help=_get_help(Settings.chown))
160
+ password: Secret[str] = secret(
161
+ default=SETTINGS.password, help=_get_help(Settings.password)
162
+ )
163
+ dry_run: bool = option(default=SETTINGS.dry_run, help=_get_help(Settings.dry_run))
164
+ exclude: list[str] | None = option(
165
+ default=SETTINGS.exclude_backup, help=_get_help(Settings.exclude_backup)
166
+ )
167
+ exclude_i: list[str] | None = option(
168
+ default=SETTINGS.exclude_i_backup, help=_get_help(Settings.exclude_i_backup)
169
+ )
170
+ read_concurrency: int = option(
171
+ default=SETTINGS.read_concurrency, help=_get_help(Settings.read_concurrency)
172
+ )
173
+ tag_backup: list[str] | None = option(
174
+ default=SETTINGS.tag_backup, help=_get_help(Settings.tag_backup)
175
+ )
176
+ run_forget: bool = option(
177
+ default=SETTINGS.run_forget, help=_get_help(Settings.run_forget)
178
+ )
179
+ keep_last: int | None = option(
180
+ default=SETTINGS.keep_last, help=_get_help(Settings.keep_last)
181
+ )
182
+ keep_hourly: int | None = option(
183
+ default=SETTINGS.keep_hourly, help=_get_help(Settings.keep_hourly)
184
+ )
185
+ keep_daily: int | None = option(
186
+ default=SETTINGS.keep_daily, help=_get_help(Settings.keep_daily)
187
+ )
188
+ keep_weekly: int | None = option(
189
+ default=SETTINGS.keep_weekly, help=_get_help(Settings.keep_weekly)
190
+ )
191
+ keep_monthly: int | None = option(
192
+ default=SETTINGS.keep_monthly, help=_get_help(Settings.keep_monthly)
193
+ )
194
+ keep_yearly: int | None = option(
195
+ default=SETTINGS.keep_yearly, help=_get_help(Settings.keep_yearly)
196
+ )
197
+ keep_within: str | None = option(
198
+ default=SETTINGS.keep_within, help=_get_help(Settings.keep_within)
199
+ )
200
+ keep_within_hourly: str | None = option(
201
+ default=SETTINGS.keep_within_hourly, help=_get_help(Settings.keep_within_hourly)
202
+ )
203
+ keep_within_daily: str | None = option(
204
+ default=SETTINGS.keep_within_daily, help=_get_help(Settings.keep_within_daily)
205
+ )
206
+ keep_within_weekly: str | None = option(
207
+ default=SETTINGS.keep_within_weekly, help=_get_help(Settings.keep_within_weekly)
208
+ )
209
+ keep_within_monthly: str | None = option(
210
+ default=SETTINGS.keep_within_monthly,
211
+ help=_get_help(Settings.keep_within_monthly),
212
+ )
213
+ keep_within_yearly: str | None = option(
214
+ default=SETTINGS.keep_within_yearly, help=_get_help(Settings.keep_within_yearly)
215
+ )
216
+ prune: bool = option(default=SETTINGS.prune, help=_get_help(Settings.prune))
217
+ repack_cacheable_only: bool = option(
218
+ default=SETTINGS.repack_cacheable_only,
219
+ help=_get_help(Settings.repack_cacheable_only),
220
+ )
221
+ repack_small: bool = option(
222
+ default=SETTINGS.repack_small, help=_get_help(Settings.repack_small)
223
+ )
224
+ repack_uncompressed: bool = option(
225
+ default=SETTINGS.repack_uncompressed,
226
+ help=_get_help(Settings.repack_uncompressed),
227
+ )
228
+ tag_forget: list[str] | None = option(
229
+ default=SETTINGS.tag_forget, help=_get_help(Settings.tag_forget)
230
+ )
231
+ sleep: int | None = option(default=SETTINGS.sleep, help=_get_help(Settings.sleep))
232
+
233
+
234
+ @settings(kw_only=True)
235
+ class CopySettings:
236
+ src_password: Secret[str] = secret(
237
+ default=SETTINGS.password, help=_get_help(Settings.password)
238
+ )
239
+ dest_password: Secret[str] = secret(
240
+ default=SETTINGS.password, help=_get_help(Settings.password)
241
+ )
242
+ tag: list[str] | None = option(
243
+ default=SETTINGS.tag_copy, help=_get_help(Settings.tag_copy)
244
+ )
245
+
246
+
247
+ @settings(kw_only=True)
248
+ class ForgetSettings:
249
+ password: Secret[str] = secret(
250
+ default=SETTINGS.password, help=_get_help(Settings.password)
251
+ )
252
+ dry_run: bool = option(default=SETTINGS.dry_run, help=_get_help(Settings.dry_run))
253
+ keep_last: int | None = option(
254
+ default=SETTINGS.keep_last, help=_get_help(Settings.keep_last)
255
+ )
256
+ keep_hourly: int | None = option(
257
+ default=SETTINGS.keep_hourly, help=_get_help(Settings.keep_hourly)
258
+ )
259
+ keep_daily: int | None = option(
260
+ default=SETTINGS.keep_daily, help=_get_help(Settings.keep_daily)
261
+ )
262
+ keep_weekly: int | None = option(
263
+ default=SETTINGS.keep_weekly, help=_get_help(Settings.keep_weekly)
264
+ )
265
+ keep_monthly: int | None = option(
266
+ default=SETTINGS.keep_monthly, help=_get_help(Settings.keep_monthly)
267
+ )
268
+ keep_yearly: int | None = option(
269
+ default=SETTINGS.keep_yearly, help=_get_help(Settings.keep_yearly)
270
+ )
271
+ keep_within: str | None = option(
272
+ default=SETTINGS.keep_within, help=_get_help(Settings.keep_within)
273
+ )
274
+ keep_within_hourly: str | None = option(
275
+ default=SETTINGS.keep_within_hourly, help=_get_help(Settings.keep_within_hourly)
276
+ )
277
+ keep_within_daily: str | None = option(
278
+ default=SETTINGS.keep_within_daily, help=_get_help(Settings.keep_within_daily)
279
+ )
280
+ keep_within_weekly: str | None = option(
281
+ default=SETTINGS.keep_within_weekly, help=_get_help(Settings.keep_within_weekly)
282
+ )
283
+ keep_within_monthly: str | None = option(
284
+ default=SETTINGS.keep_within_monthly,
285
+ help=_get_help(Settings.keep_within_monthly),
286
+ )
287
+ keep_within_yearly: str | None = option(
288
+ default=SETTINGS.keep_within_yearly, help=_get_help(Settings.keep_within_yearly)
289
+ )
290
+ prune: bool = option(default=SETTINGS.prune, help=_get_help(Settings.prune))
291
+ repack_cacheable_only: bool = option(
292
+ default=SETTINGS.repack_cacheable_only,
293
+ help=_get_help(Settings.repack_cacheable_only),
294
+ )
295
+ repack_small: bool = option(
296
+ default=SETTINGS.repack_small, help=_get_help(Settings.repack_small)
297
+ )
298
+ repack_uncompressed: bool = option(
299
+ default=SETTINGS.repack_uncompressed,
300
+ help=_get_help(Settings.repack_uncompressed),
301
+ )
302
+ tag: list[str] | None = option(
303
+ default=SETTINGS.tag_forget, help=_get_help(Settings.tag_forget)
304
+ )
305
+
306
+
307
+ @settings(kw_only=True)
308
+ class RestoreSettings:
309
+ password: Secret[str] = secret(
310
+ default=SETTINGS.password, help=_get_help(Settings.password)
311
+ )
312
+ delete: bool = option(default=SETTINGS.delete, help=_get_help(Settings.delete))
313
+ dry_run: bool = option(default=SETTINGS.dry_run, help=_get_help(Settings.dry_run))
314
+ exclude: list[str] | None = option(
315
+ default=SETTINGS.exclude_restore, help=_get_help(Settings.exclude_restore)
316
+ )
317
+ exclude_i: list[str] | None = option(
318
+ default=SETTINGS.exclude_i_restore, help=_get_help(Settings.exclude_i_restore)
319
+ )
320
+ include: list[str] | None = option(
321
+ default=SETTINGS.include_restore, help=_get_help(Settings.include_restore)
322
+ )
323
+ include_i: list[str] | None = option(
324
+ default=SETTINGS.include_i_restore, help=_get_help(Settings.include_i_restore)
325
+ )
326
+ tag: list[str] | None = option(
327
+ default=SETTINGS.tag_restore, help=_get_help(Settings.tag_restore)
328
+ )
329
+ snapshot: str = option(default=SETTINGS.snapshot, help=_get_help(Settings.snapshot))
330
+
331
+
332
+ @settings(kw_only=True)
333
+ class SnapshotsSettings:
334
+ password: Secret[str] = secret(
335
+ default=SETTINGS.password, help=_get_help(Settings.password)
336
+ )
337
+
338
+
339
+ __all__ = [
340
+ "LOADERS",
341
+ "SETTINGS",
342
+ "BackupSettings",
343
+ "CopySettings",
344
+ "ForgetSettings",
345
+ "InitSettings",
346
+ "RestoreSettings",
347
+ "Settings",
348
+ "SnapshotsSettings",
349
+ ]
restic/types.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typed_settings import Secret
4
+ from utilities.types import PathLike
5
+
6
+ type PasswordLike = Secret[str] | PathLike
7
+ type SecretLike = Secret[str] | str
8
+
9
+ __all__ = ["PasswordLike", "SecretLike"]
restic/utilities.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from itertools import chain
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Literal, assert_never
7
+
8
+ from typed_settings import Secret
9
+ from utilities.os import temp_environ
10
+ from utilities.subprocess import run
11
+ from utilities.tempfile import TemporaryFile
12
+
13
+ from restic.settings import SETTINGS
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
18
+ from utilities.types import PathLike
19
+
20
+ from restic.types import PasswordLike
21
+
22
+
23
+ def expand_bool(flag: str, /, *, bool_: bool = False) -> list[str]:
24
+ return [f"--{flag}"] if bool_ else []
25
+
26
+
27
+ def expand_dry_run(*, dry_run: bool = False) -> list[str]:
28
+ return expand_bool("dry-run", bool_=dry_run)
29
+
30
+
31
+ def expand_exclude(*, exclude: list[str] | None = None) -> list[str]:
32
+ return _expand_list("exclude", arg=exclude)
33
+
34
+
35
+ def expand_exclude_i(*, exclude_i: list[str] | None = None) -> list[str]:
36
+ return _expand_list("iexclude", arg=exclude_i)
37
+
38
+
39
+ def expand_include(*, include: list[str] | None = None) -> list[str]:
40
+ return _expand_list("include", arg=include)
41
+
42
+
43
+ def expand_include_i(*, include_i: list[str] | None = None) -> list[str]:
44
+ return _expand_list("iinclude", arg=include_i)
45
+
46
+
47
+ def expand_keep(freq: str, /, *, n: int | None = None) -> list[str]:
48
+ return [] if n is None else [f"--keep-{freq}", str(n)]
49
+
50
+
51
+ def expand_keep_within(freq: str, /, *, duration: str | None = None) -> list[str]:
52
+ return [] if duration is None else [f"--keep-{freq}", duration]
53
+
54
+
55
+ def expand_tag(*, tag: list[str] | None = None) -> list[str]:
56
+ return _expand_list("tag", arg=tag)
57
+
58
+
59
+ def run_chmod(path: PathLike, type_: Literal["f", "d"], mode: str, /) -> None:
60
+ run("sudo", "find", str(path), "-type", type_, "-exec", "chmod", mode, "{}", "+")
61
+
62
+
63
+ @contextmanager
64
+ def yield_password(
65
+ *, password: PasswordLike = SETTINGS.password, env_var: str = "RESTIC_PASSWORD_FILE"
66
+ ) -> Iterator[None]:
67
+ match password:
68
+ case Secret():
69
+ value = password.get_secret_value()
70
+ case Path() | str() as value:
71
+ ...
72
+ case never:
73
+ assert_never(never)
74
+ match value:
75
+ case Path():
76
+ if value.is_file():
77
+ with temp_environ({env_var: str(value)}):
78
+ yield
79
+ else:
80
+ msg = f"Password file not found: '{value!s}'"
81
+ raise FileNotFoundError(msg)
82
+ case str():
83
+ if Path(value).is_file():
84
+ with temp_environ({env_var: value}):
85
+ yield
86
+ else:
87
+ with TemporaryFile() as temp, temp_environ({env_var: str(temp)}):
88
+ _ = temp.write_text(value)
89
+ yield
90
+ case never:
91
+ assert_never(never)
92
+
93
+
94
+ def _expand_list(flag: str, /, *, arg: list[str] | None = None) -> list[str]:
95
+ return (
96
+ [] if arg is None else list(chain.from_iterable([f"--{flag}", a] for a in arg))
97
+ )
98
+
99
+
100
+ __all__ = [
101
+ "expand_bool",
102
+ "expand_dry_run",
103
+ "expand_exclude",
104
+ "expand_exclude_i",
105
+ "expand_include",
106
+ "expand_include_i",
107
+ "expand_keep",
108
+ "expand_keep_within",
109
+ "expand_tag",
110
+ "run_chmod",
111
+ "yield_password",
112
+ ]