dycw-restic 0.2.22__py3-none-any.whl → 0.3.9__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.
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dycw-restic
3
- Version: 0.2.22
3
+ Version: 0.3.9
4
4
  Summary: Library to operate `restic`
5
5
  Author: Derek Wan
6
6
  Author-email: Derek Wan <d.wan@icloud.com>
7
7
  Requires-Dist: click>=8.3.1,<9
8
- Requires-Dist: dycw-utilities>=0.172.5,<1
8
+ Requires-Dist: dycw-utilities>=0.176.3,<1
9
+ Requires-Dist: pydantic>=2.12.5,<3
9
10
  Requires-Dist: typed-settings[attrs,click]>=25.3.0,<26
10
- Requires-Python: >=3.14
11
+ Requires-Python: >=3.12
11
12
  Description-Content-Type: text/markdown
12
13
 
13
14
  # `restic`
@@ -0,0 +1,16 @@
1
+ restic/__init__.py,sha256=1R3lI2sjBNAPTtK4HP7ZglqUphNcoj7YV8YvqS_AsVE,58
2
+ restic/cli.py,sha256=TK2SGlaxCusUrm0TEQ7TpOtgNeXo_18UmHhx4CcvoLQ,6035
3
+ restic/click.py,sha256=LsM3wEHablcV7vsdAHJAgde4l8mikOW7MHy67ICeJ90,953
4
+ restic/constants.py,sha256=uu2dXIlx3vyMNgJUs1lrCMOYBPGGUviVJ5qvxqOx6Tw,911
5
+ restic/lib.py,sha256=vGkoeSoqpV9q5RpS6EA4JxZ8v314aihH0kRzUFNX4fk,12875
6
+ restic/logging.py,sha256=tzoiz1F2T-AavxahNduXxfnFvhmcBh6JHskDgUhcNVU,119
7
+ restic/models.py,sha256=UEVhXzXTaoHxrOmXR9-RSaxuC0Q_AH4RWwFfCrWL3ek,805
8
+ restic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ restic/repo.py,sha256=wzSvqtxXvlYg_3Fjv7xc3-H_sGxwHcCPVXM_Z3cTK_E,6870
10
+ restic/settings.py,sha256=TJraIkjJVDzu3ReuQIXKVry1Nwn2YR1uWNT4RYzKvsU,13588
11
+ restic/types.py,sha256=lCchCT7Y7yo0PMBGHgYjpuTDf9z2orPjKiPuje1vfmE,229
12
+ restic/utilities.py,sha256=PROHOEqEm5KM9p9LoSggVMd5kxX2-nyuAMS_YBcU4QY,3690
13
+ dycw_restic-0.3.9.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
14
+ dycw_restic-0.3.9.dist-info/entry_points.txt,sha256=6byn-7KAIlNAdTjaNQ3DjylzzazsZB8SLFvvrqv641o,48
15
+ dycw_restic-0.3.9.dist-info/METADATA,sha256=kkeyYE1-GUITJGBJQX7oxXosW63OTXAPaRkSQfYx38M,421
16
+ dycw_restic-0.3.9.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.18
2
+ Generator: uv 0.9.21
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
restic/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.22"
3
+ __version__ = "0.3.9"
restic/cli.py CHANGED
@@ -12,7 +12,7 @@ from utilities.os import is_pytest
12
12
 
13
13
  import restic.click
14
14
  import restic.repo
15
- from restic.lib import backup, copy, forget, init, restore, snapshots
15
+ from restic.lib import backup, copy, forget, init, restore, snapshots, unlock
16
16
  from restic.logging import LOGGER
17
17
  from restic.settings import (
18
18
  LOADERS,
@@ -22,6 +22,7 @@ from restic.settings import (
22
22
  InitSettings,
23
23
  RestoreSettings,
24
24
  SnapshotsSettings,
25
+ UnlockSettings,
25
26
  )
26
27
 
27
28
  if TYPE_CHECKING:
@@ -38,6 +39,7 @@ def _main() -> None: ...
38
39
  def init_sub_cmd(settings: InitSettings, /, *, repo: restic.repo.Repo) -> None:
39
40
  if is_pytest():
40
41
  return
42
+ basic_config(obj=LOGGER)
41
43
  init(repo, password=settings.password)
42
44
 
43
45
 
@@ -50,11 +52,10 @@ def backup_sub_cmd(
50
52
  ) -> None:
51
53
  if is_pytest():
52
54
  return
55
+ basic_config(obj=LOGGER)
53
56
  backup(
54
57
  path,
55
58
  repo,
56
- chmod=settings.chmod,
57
- chown=settings.chown,
58
59
  password=settings.password,
59
60
  dry_run=settings.dry_run,
60
61
  exclude=settings.exclude,
@@ -74,6 +75,7 @@ def backup_sub_cmd(
74
75
  keep_within_weekly=settings.keep_within_weekly,
75
76
  keep_within_monthly=settings.keep_within_monthly,
76
77
  keep_within_yearly=settings.keep_within_yearly,
78
+ group_by=settings.group_by,
77
79
  prune=settings.prune,
78
80
  repack_cacheable_only=settings.repack_cacheable_only,
79
81
  repack_small=settings.repack_small,
@@ -92,12 +94,14 @@ def copy_sub_cmd(
92
94
  ) -> None:
93
95
  if is_pytest():
94
96
  return
97
+ basic_config(obj=LOGGER)
95
98
  copy(
96
99
  src,
97
100
  dest,
98
101
  src_password=settings.src_password,
99
102
  dest_password=settings.dest_password,
100
103
  tag=settings.tag,
104
+ sleep=settings.sleep,
101
105
  )
102
106
 
103
107
 
@@ -107,6 +111,7 @@ def copy_sub_cmd(
107
111
  def forget_sub_cmd(settings: ForgetSettings, /, *, repo: restic.repo.Repo) -> None:
108
112
  if is_pytest():
109
113
  return
114
+ basic_config(obj=LOGGER)
110
115
  forget(
111
116
  repo,
112
117
  password=settings.password,
@@ -123,6 +128,7 @@ def forget_sub_cmd(settings: ForgetSettings, /, *, repo: restic.repo.Repo) -> No
123
128
  keep_within_weekly=settings.keep_within_weekly,
124
129
  keep_within_monthly=settings.keep_within_monthly,
125
130
  keep_within_yearly=settings.keep_within_yearly,
131
+ group_by=settings.group_by,
126
132
  prune=settings.prune,
127
133
  repack_cacheable_only=settings.repack_cacheable_only,
128
134
  repack_small=settings.repack_small,
@@ -131,6 +137,16 @@ def forget_sub_cmd(settings: ForgetSettings, /, *, repo: restic.repo.Repo) -> No
131
137
  )
132
138
 
133
139
 
140
+ @_main.command(name="unlock", **CONTEXT_SETTINGS)
141
+ @argument("repo", type=restic.click.Repo())
142
+ @click_options(UnlockSettings, LOADERS, show_envvars_in_help=True)
143
+ def unlock_sub_cmd(settings: UnlockSettings, /, *, repo: restic.repo.Repo) -> None:
144
+ if is_pytest():
145
+ return
146
+ basic_config(obj=LOGGER)
147
+ unlock(repo, password=settings.password, remove_all=settings.remove_all)
148
+
149
+
134
150
  @_main.command(name="restore", **CONTEXT_SETTINGS)
135
151
  @argument("repo", type=restic.click.Repo())
136
152
  @argument("target", type=click.Path(path_type=Path))
@@ -140,6 +156,7 @@ def restore_sub_cmd(
140
156
  ) -> None:
141
157
  if is_pytest():
142
158
  return
159
+ basic_config(obj=LOGGER)
143
160
  restore(
144
161
  repo,
145
162
  target,
@@ -163,9 +180,9 @@ def snapshots_sub_cmd(
163
180
  ) -> None:
164
181
  if is_pytest():
165
182
  return
183
+ basic_config(obj=LOGGER)
166
184
  snapshots(repo, password=settings.password)
167
185
 
168
186
 
169
187
  if __name__ == "__main__":
170
- basic_config(obj=LOGGER)
171
188
  _main()
restic/click.py CHANGED
@@ -1,14 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from contextlib import suppress
4
- from pathlib import Path
5
- from re import search
6
3
  from typing import TYPE_CHECKING, assert_never, override
7
4
 
8
5
  from click import Context, Parameter, ParamType
9
- from utilities.re import ExtractGroupError, ExtractGroupsError
10
6
 
11
- from restic.repo import SFTP, Backblaze, Local
7
+ from restic.repo import SFTP, Backblaze, Local, ParseRepoBackblazeError, parse_repo
12
8
 
13
9
  if TYPE_CHECKING:
14
10
  import restic.repo
@@ -33,17 +29,9 @@ class Repo(ParamType):
33
29
  return value
34
30
  case str():
35
31
  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))
32
+ return parse_repo(value)
33
+ except ParseRepoBackblazeError as error:
34
+ return self.fail(str(error), param, ctx)
47
35
  case never:
48
36
  assert_never(never)
49
37
 
restic/lib.py CHANGED
@@ -3,12 +3,16 @@ from __future__ import annotations
3
3
  import time
4
4
  from re import MULTILINE, search
5
5
  from subprocess import CalledProcessError
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, Literal, overload
7
7
 
8
- from utilities.subprocess import run
8
+ from pydantic import TypeAdapter
9
+ from utilities.iterables import one
10
+ from utilities.subprocess import cp, run
11
+ from utilities.tempfile import TemporaryDirectory
9
12
  from whenever import TimeDelta
10
13
 
11
14
  from restic.logging import LOGGER
15
+ from restic.models import Snapshot
12
16
  from restic.repo import yield_repo_env
13
17
  from restic.settings import SETTINGS
14
18
  from restic.utilities import (
@@ -16,12 +20,14 @@ from restic.utilities import (
16
20
  expand_dry_run,
17
21
  expand_exclude,
18
22
  expand_exclude_i,
23
+ expand_group_by,
19
24
  expand_include,
20
25
  expand_include_i,
21
26
  expand_keep,
22
27
  expand_keep_within,
28
+ expand_read_concurrency,
23
29
  expand_tag,
24
- run_chmod,
30
+ expand_target,
25
31
  yield_password,
26
32
  )
27
33
 
@@ -37,8 +43,6 @@ def backup(
37
43
  repo: Repo,
38
44
  /,
39
45
  *,
40
- chmod: bool = SETTINGS.chmod,
41
- chown: str | None = SETTINGS.chown,
42
46
  password: PasswordLike = SETTINGS.password,
43
47
  dry_run: bool = SETTINGS.dry_run,
44
48
  exclude: list[str] | None = SETTINGS.exclude_backup,
@@ -58,6 +62,7 @@ def backup(
58
62
  keep_within_weekly: str | None = SETTINGS.keep_within_weekly,
59
63
  keep_within_monthly: str | None = SETTINGS.keep_within_monthly,
60
64
  keep_within_yearly: str | None = SETTINGS.keep_within_yearly,
65
+ group_by: list[str] | None = SETTINGS.group_by,
61
66
  prune: bool = SETTINGS.prune,
62
67
  repack_cacheable_only: bool = SETTINGS.repack_cacheable_only,
63
68
  repack_small: bool = SETTINGS.repack_small,
@@ -66,11 +71,6 @@ def backup(
66
71
  sleep: int | None = SETTINGS.sleep,
67
72
  ) -> None:
68
73
  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
74
  try:
75
75
  _backup_core(
76
76
  path,
@@ -102,7 +102,20 @@ def backup(
102
102
  )
103
103
  else:
104
104
  raise
105
- if run_forget:
105
+ if run_forget and (
106
+ (keep_last is not None)
107
+ or (keep_hourly is not None)
108
+ or (keep_daily is not None)
109
+ or (keep_weekly is not None)
110
+ or (keep_monthly is not None)
111
+ or (keep_yearly is not None)
112
+ or (keep_within is not None)
113
+ or (keep_within_hourly is not None)
114
+ or (keep_within_daily is not None)
115
+ or (keep_within_weekly is not None)
116
+ or (keep_within_monthly is not None)
117
+ or (keep_within_yearly is not None)
118
+ ):
106
119
  forget(
107
120
  repo,
108
121
  password=password,
@@ -118,6 +131,7 @@ def backup(
118
131
  keep_within_weekly=keep_within_weekly,
119
132
  keep_within_monthly=keep_within_monthly,
120
133
  keep_within_yearly=keep_within_yearly,
134
+ group_by=group_by,
121
135
  prune=prune,
122
136
  repack_cacheable_only=repack_cacheable_only,
123
137
  repack_small=repack_small,
@@ -154,21 +168,13 @@ def _backup_core(
154
168
  *expand_dry_run(dry_run=dry_run),
155
169
  *expand_exclude(exclude=exclude),
156
170
  *expand_exclude_i(exclude_i=exclude_i),
157
- "--read-concurrency",
158
- str(read_concurrency),
171
+ *expand_read_concurrency(read_concurrency),
159
172
  *expand_tag(tag=tag),
160
173
  str(path),
161
174
  print=True,
162
175
  )
163
176
 
164
177
 
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
178
  def copy(
173
179
  src: Repo,
174
180
  dest: Repo,
@@ -177,6 +183,7 @@ def copy(
177
183
  src_password: PasswordLike = SETTINGS.password,
178
184
  dest_password: PasswordLike = SETTINGS.password,
179
185
  tag: list[str] | None = SETTINGS.tag_copy,
186
+ sleep: int | None = SETTINGS.sleep,
180
187
  ) -> None:
181
188
  LOGGER.info("Copying snapshots from '%s' to '%s'...", src, dest)
182
189
  with (
@@ -186,7 +193,15 @@ def copy(
186
193
  yield_password(password=dest_password),
187
194
  ):
188
195
  run("restic", "copy", *expand_tag(tag=tag), print=True)
189
- LOGGER.info("Finished copying snapshots from '%s' to '%s'", src, dest)
196
+ if sleep is None:
197
+ LOGGER.info("Finished copying snapshots from '%s' to '%s'", src, dest)
198
+ else:
199
+ delta = TimeDelta(seconds=sleep)
200
+ LOGGER.info(
201
+ "Finished copying snapshots from '%s' to '%s'; sleeping for %s...", delta
202
+ )
203
+ time.sleep(sleep)
204
+ LOGGER.info("Finishing sleeping for %s", delta)
190
205
 
191
206
 
192
207
  def forget(
@@ -207,6 +222,7 @@ def forget(
207
222
  keep_within_weekly: str | None = SETTINGS.keep_within_weekly,
208
223
  keep_within_monthly: str | None = SETTINGS.keep_within_monthly,
209
224
  keep_within_yearly: str | None = SETTINGS.keep_within_yearly,
225
+ group_by: list[str] | None = SETTINGS.group_by,
210
226
  prune: bool = SETTINGS.prune,
211
227
  repack_cacheable_only: bool = SETTINGS.repack_cacheable_only,
212
228
  repack_small: bool = SETTINGS.repack_small,
@@ -231,6 +247,7 @@ def forget(
231
247
  *expand_keep_within("within-weekly", duration=keep_within_weekly),
232
248
  *expand_keep_within("within-monthly", duration=keep_within_monthly),
233
249
  *expand_keep_within("within-yearly", duration=keep_within_yearly),
250
+ *expand_group_by(group_by=group_by),
234
251
  *expand_bool("prune", bool_=prune),
235
252
  *expand_bool("repack-cacheable-only", bool_=repack_cacheable_only),
236
253
  *expand_bool("repack-small", bool_=repack_small),
@@ -241,6 +258,28 @@ def forget(
241
258
  LOGGER.info("Finished forgetting snapshots in '%s'", repo)
242
259
 
243
260
 
261
+ def init(repo: Repo, /, *, password: PasswordLike = SETTINGS.password) -> None:
262
+ LOGGER.info("Initializing '%s'", repo)
263
+ with yield_repo_env(repo), yield_password(password=password):
264
+ run("restic", "init", print=True)
265
+ LOGGER.info("Finished initializing '%s'", repo)
266
+
267
+
268
+ def unlock(
269
+ repo: Repo,
270
+ /,
271
+ *,
272
+ password: PasswordLike = SETTINGS.password,
273
+ remove_all: bool = SETTINGS.remove_all,
274
+ ) -> None:
275
+ LOGGER.info("Unlocking '%s'", repo)
276
+ with yield_repo_env(repo), yield_password(password=password):
277
+ run(
278
+ "restic", "unlock", *expand_bool("remove-all", bool_=remove_all), print=True
279
+ )
280
+ LOGGER.info("Finished unlocking '%s'", repo)
281
+
282
+
244
283
  def restore(
245
284
  repo: Repo,
246
285
  target: PathLike,
@@ -257,7 +296,11 @@ def restore(
257
296
  snapshot: str = SETTINGS.snapshot,
258
297
  ) -> None:
259
298
  LOGGER.info("Restoring snapshot '%s' of '%s' to '%s'...", snapshot, repo, target)
260
- with yield_repo_env(repo), yield_password(password=password):
299
+ with (
300
+ yield_repo_env(repo),
301
+ yield_password(password=password),
302
+ TemporaryDirectory() as temp,
303
+ ):
261
304
  run(
262
305
  "restic",
263
306
  "restore",
@@ -268,22 +311,63 @@ def restore(
268
311
  *expand_include(include=include),
269
312
  *expand_include_i(include_i=include_i),
270
313
  *expand_tag(tag=tag),
271
- "--target",
272
- str(target),
314
+ *expand_target(temp),
273
315
  "--verify",
274
316
  snapshot,
275
317
  print=True,
276
318
  )
319
+ snaps = snapshots(repo, password=password, return_=True)
320
+ path = one(snaps[-1].paths)
321
+ src = temp / path.relative_to(path.anchor)
322
+ cp(src, target)
277
323
  LOGGER.info(
278
324
  "Finished restoring snapshot '%s' of '%s' to '%s'", snapshot, repo, target
279
325
  )
280
326
 
281
327
 
282
- def snapshots(repo: Repo, /, *, password: PasswordLike = SETTINGS.password) -> None:
328
+ @overload
329
+ def snapshots(
330
+ repo: Repo,
331
+ /,
332
+ *,
333
+ password: PasswordLike = SETTINGS.password,
334
+ return_: Literal[True],
335
+ print: bool = False,
336
+ ) -> list[Snapshot]: ...
337
+ @overload
338
+ def snapshots(
339
+ repo: Repo,
340
+ /,
341
+ *,
342
+ password: PasswordLike = SETTINGS.password,
343
+ return_: Literal[False] = False,
344
+ print: bool = False,
345
+ ) -> None: ...
346
+ @overload
347
+ def snapshots(
348
+ repo: Repo,
349
+ /,
350
+ *,
351
+ password: PasswordLike = SETTINGS.password,
352
+ return_: bool = False,
353
+ print: bool = False,
354
+ ) -> list[Snapshot] | None: ...
355
+ def snapshots(
356
+ repo: Repo,
357
+ /,
358
+ *,
359
+ password: PasswordLike = SETTINGS.password,
360
+ return_: bool = False,
361
+ print: bool = False, # noqa: A002
362
+ ) -> list[Snapshot] | None:
283
363
  LOGGER.info("Listing snapshots in '%s'...", repo)
364
+ args: list[str] = ["restic", "snapshots"]
365
+ if return_:
366
+ args.append("--json")
284
367
  with yield_repo_env(repo), yield_password(password=password):
285
- run("restic", "snapshots", print=True)
368
+ result = run(*args, print=print, return_=return_)
286
369
  LOGGER.info("Finished listing snapshots in '%s'", repo)
370
+ return None if result is None else TypeAdapter(list[Snapshot]).validate_json(result)
287
371
 
288
372
 
289
- __all__ = ["backup", "copy", "forget", "init", "restore", "snapshots"]
373
+ __all__ = ["backup", "copy", "forget", "init", "restore", "snapshots", "unlock"]
restic/models.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt # noqa: TC003
4
+ from pathlib import Path # noqa: TC003
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Snapshot(BaseModel):
10
+ time: dt.datetime
11
+ tree: str
12
+ paths: list[Path]
13
+ hostname: str
14
+ username: str
15
+ uid: int
16
+ gid: int
17
+ program_version: str
18
+ summary: Summary
19
+ id: str
20
+ short_id: str
21
+
22
+
23
+ class Summary(BaseModel):
24
+ backup_start: dt.datetime
25
+ backup_end: dt.datetime
26
+ files_new: int
27
+ files_changed: int
28
+ files_unmodified: int
29
+ dirs_new: int
30
+ dirs_changed: int
31
+ dirs_unmodified: int
32
+ data_blobs: int
33
+ tree_blobs: int
34
+ data_added: int
35
+ data_added_packed: int
36
+ total_files_processed: int
37
+ total_bytes_processed: int
38
+
39
+
40
+ _ = Snapshot.model_rebuild()
41
+
42
+
43
+ __all__ = ["Snapshot", "Summary"]
restic/repo.py CHANGED
@@ -1,13 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from contextlib import contextmanager
3
+ from contextlib import contextmanager, suppress
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Self, assert_never
6
+ from re import search
7
+ from typing import TYPE_CHECKING, Self, assert_never, override
7
8
 
8
9
  from typed_settings import Secret, load_settings
9
10
  from utilities.os import temp_environ
10
- from utilities.re import extract_group, extract_groups
11
+ from utilities.re import (
12
+ ExtractGroupError,
13
+ ExtractGroupsError,
14
+ extract_group,
15
+ extract_groups,
16
+ )
11
17
 
12
18
  from restic.settings import LOADERS, SETTINGS, Settings
13
19
 
@@ -20,13 +26,38 @@ if TYPE_CHECKING:
20
26
  type Repo = Backblaze | Local | SFTP
21
27
 
22
28
 
23
- @dataclass(order=True, unsafe_hash=True, slots=True)
29
+ ##
30
+
31
+
32
+ @dataclass(order=True, slots=True)
24
33
  class Backblaze:
25
34
  key_id: Secret[str]
26
35
  application_key: Secret[str]
27
36
  bucket: str
28
37
  path: Path
29
38
 
39
+ @override
40
+ def __eq__(self, other: object, /) -> bool:
41
+ return (
42
+ isinstance(other, type(self))
43
+ and (self.key_id.get_secret_value() == other.key_id.get_secret_value())
44
+ and (
45
+ self.application_key.get_secret_value()
46
+ == other.application_key.get_secret_value()
47
+ )
48
+ and (self.bucket == other.bucket)
49
+ and (self.path == other.path)
50
+ )
51
+
52
+ @override
53
+ def __hash__(self) -> int:
54
+ return hash((
55
+ self.key_id.get_secret_value(),
56
+ self.application_key.get_secret_value(),
57
+ self.bucket,
58
+ self.path,
59
+ ))
60
+
30
61
  @classmethod
31
62
  def parse(
32
63
  cls,
@@ -45,8 +76,7 @@ class Backblaze:
45
76
  case None, Secret() as key_id_use:
46
77
  ...
47
78
  case None, None:
48
- msg = "'BACKBLAZE_KEY_ID' is missing"
49
- raise ValueError(msg)
79
+ raise BackblazeKeyIdMissingError
50
80
  case never:
51
81
  assert_never(never)
52
82
  match application_key, settings.backblaze_application_key:
@@ -57,11 +87,14 @@ class Backblaze:
57
87
  case None, Secret() as application_key_use:
58
88
  ...
59
89
  case None, None:
60
- msg = "'BACKBLAZE_APPLICATION_KEY' is missing"
61
- raise ValueError(msg)
90
+ raise BackblazeApplicationIdMissingError
62
91
  case never:
63
92
  assert_never(never)
64
- bucket, path = extract_groups(r"^b2:([^@:]+):([^@+]+)$", text)
93
+ pattern = r"^b2:([^@:]+):([^@+]+)$"
94
+ try:
95
+ bucket, path = extract_groups(pattern, text)
96
+ except ExtractGroupsError:
97
+ raise BackblazeInvalidStrError(pattern=pattern, text=text) from None
65
98
  return cls(key_id_use, application_key_use, bucket, Path(path))
66
99
 
67
100
  @property
@@ -69,13 +102,48 @@ class Backblaze:
69
102
  return f"b2:{self.bucket}:{self.path}"
70
103
 
71
104
 
105
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
106
+ class BackblazeParseError(Exception): ...
107
+
108
+
109
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
110
+ class BackblazeKeyIdMissingError(BackblazeParseError):
111
+ @override
112
+ def __str__(self) -> str:
113
+ return "'BACKBLAZE_KEY_ID' is missing"
114
+
115
+
116
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
117
+ class BackblazeApplicationIdMissingError(BackblazeParseError):
118
+ @override
119
+ def __str__(self) -> str:
120
+ return "'BACKBLAZE_APPLICATION_ID' is missing"
121
+
122
+
123
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
124
+ class BackblazeInvalidStrError(BackblazeParseError):
125
+ pattern: str
126
+ text: str
127
+
128
+ @override
129
+ def __str__(self) -> str:
130
+ return f"Text must be of the form {self.pattern!r}; got {self.text!r}"
131
+
132
+
133
+ ##
134
+
135
+
72
136
  @dataclass(order=True, unsafe_hash=True, slots=True)
73
137
  class Local:
74
138
  path: Path
75
139
 
76
140
  @classmethod
77
141
  def parse(cls, text: str, /) -> Self:
78
- path = extract_group(r"^local:([^@:]+)$", text)
142
+ pattern = r"^local:([^@:]+)$"
143
+ try:
144
+ path = extract_group(pattern, text)
145
+ except ExtractGroupError:
146
+ raise LocalParseError(pattern=pattern, text=text) from None
79
147
  return cls(Path(path))
80
148
 
81
149
  @property
@@ -83,6 +151,19 @@ class Local:
83
151
  return f"local:{self.path}"
84
152
 
85
153
 
154
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
155
+ class LocalParseError(Exception):
156
+ pattern: str
157
+ text: str
158
+
159
+ @override
160
+ def __str__(self) -> str:
161
+ return f"Text must be of the form {self.pattern!r}; got {self.text!r}"
162
+
163
+
164
+ ##
165
+
166
+
86
167
  @dataclass(order=True, unsafe_hash=True, slots=True)
87
168
  class SFTP:
88
169
  user: str
@@ -91,9 +172,11 @@ class SFTP:
91
172
 
92
173
  @classmethod
93
174
  def parse(cls, text: str, /) -> Self:
94
- user, hostname, path = extract_groups(
95
- r"^sftp:([^@:]+)@([^@:]+):([^@:]+)$", text
96
- )
175
+ pattern = r"^sftp:([^@:]+)@([^@:]+):([^@:]+)$"
176
+ try:
177
+ user, hostname, path = extract_groups(pattern, text)
178
+ except ExtractGroupsError:
179
+ raise SFTPParseError(pattern=pattern, text=text) from None
97
180
  return cls(user, hostname, Path(path))
98
181
 
99
182
  @property
@@ -101,6 +184,45 @@ class SFTP:
101
184
  return f"sftp:{self.user}@{self.hostname}:{self.path}"
102
185
 
103
186
 
187
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
188
+ class SFTPParseError(Exception):
189
+ pattern: str
190
+ text: str
191
+
192
+ @override
193
+ def __str__(self) -> str:
194
+ return f"Text must be of the form {self.pattern!r}; got {self.text!r}"
195
+
196
+
197
+ ##
198
+
199
+
200
+ def parse_repo(text: str, /) -> Repo:
201
+ try:
202
+ return Backblaze.parse(text)
203
+ except BackblazeParseError as error:
204
+ if search("b2", text):
205
+ raise ParseRepoBackblazeError(text=str(error)) from None
206
+ with suppress(SFTPParseError):
207
+ return SFTP.parse(text)
208
+ try:
209
+ return Local.parse(text)
210
+ except LocalParseError:
211
+ return Local(Path(text))
212
+
213
+
214
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
215
+ class ParseRepoBackblazeError(Exception):
216
+ text: str
217
+
218
+ @override
219
+ def __str__(self) -> str:
220
+ return self.text
221
+
222
+
223
+ ##
224
+
225
+
104
226
  @contextmanager
105
227
  def yield_repo_env(
106
228
  repo: Repo, /, *, env_var: str = "RESTIC_REPOSITORY"
@@ -120,4 +242,18 @@ def yield_repo_env(
120
242
  assert_never(never)
121
243
 
122
244
 
123
- __all__ = ["SFTP", "Backblaze", "Local", "Repo", "yield_repo_env"]
245
+ __all__ = [
246
+ "SFTP",
247
+ "Backblaze",
248
+ "BackblazeApplicationIdMissingError",
249
+ "BackblazeInvalidStrError",
250
+ "BackblazeKeyIdMissingError",
251
+ "BackblazeParseError",
252
+ "Local",
253
+ "LocalParseError",
254
+ "ParseRepoBackblazeError",
255
+ "Repo",
256
+ "SFTPParseError",
257
+ "parse_repo",
258
+ "yield_repo_env",
259
+ ]
restic/settings.py CHANGED
@@ -17,6 +17,8 @@ from typed_settings import (
17
17
  )
18
18
  from utilities.os import CPU_COUNT
19
19
 
20
+ from restic.logging import LOGGER
21
+
20
22
  CONFIG_FILE = getenv("RESTIC_CONFIG_FILE", "config.toml")
21
23
  SECRETS_FILE = getenv("RESTIC_SECRETS_FILE", "secrets.toml")
22
24
  LOADERS = [
@@ -38,10 +40,6 @@ class Settings:
38
40
  default=None, help="Backblaze application key"
39
41
  )
40
42
  # 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
43
  exclude_backup: list[str] | None = option(default=None, help="Exclude a pattern")
46
44
  exclude_i_backup: list[str] | None = option(
47
45
  default=None, help="Exclude a pattern but ignores the casing of filenames"
@@ -101,6 +99,9 @@ class Settings:
101
99
  default=None,
102
100
  help="Keep yearly snapshots that are newer than duration relative to the latest snapshot",
103
101
  )
102
+ group_by: list[str] | None = option(
103
+ default=None, help="Group snapshots by host, paths and/or tags"
104
+ )
104
105
  prune: bool = option(
105
106
  default=True,
106
107
  help="Automatically run the 'prune' command if snapshots have been removed",
@@ -117,6 +118,10 @@ class Settings:
117
118
  tag_forget: list[str] | None = option(
118
119
  default=None, help="Only consider snapshots including tag[,tag,...]"
119
120
  )
121
+ # unlock
122
+ remove_all: bool = option(
123
+ default=False, help="Remove all locks, even non-stale ones"
124
+ )
120
125
  # restore
121
126
  delete: bool = option(
122
127
  default=False,
@@ -137,6 +142,7 @@ class Settings:
137
142
  snapshot: str = option(default="latest", help="Snapshot ID to restore")
138
143
 
139
144
 
145
+ LOGGER.info("Loading settings from '%s' and '%s'...", CONFIG_FILE, SECRETS_FILE)
140
146
  SETTINGS = load_settings(Settings, LOADERS)
141
147
 
142
148
 
@@ -146,17 +152,8 @@ def _get_help(member_descriptor: Any, /) -> None:
146
152
  ]
147
153
 
148
154
 
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
155
  @settings(kw_only=True)
157
156
  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
157
  password: Secret[str] = secret(
161
158
  default=SETTINGS.password, help=_get_help(Settings.password)
162
159
  )
@@ -213,6 +210,9 @@ class BackupSettings:
213
210
  keep_within_yearly: str | None = option(
214
211
  default=SETTINGS.keep_within_yearly, help=_get_help(Settings.keep_within_yearly)
215
212
  )
213
+ group_by: list[str] | None = option(
214
+ default=SETTINGS.group_by, help=_get_help(Settings.group_by)
215
+ )
216
216
  prune: bool = option(default=SETTINGS.prune, help=_get_help(Settings.prune))
217
217
  repack_cacheable_only: bool = option(
218
218
  default=SETTINGS.repack_cacheable_only,
@@ -242,6 +242,7 @@ class CopySettings:
242
242
  tag: list[str] | None = option(
243
243
  default=SETTINGS.tag_copy, help=_get_help(Settings.tag_copy)
244
244
  )
245
+ sleep: int | None = option(default=SETTINGS.sleep, help=_get_help(Settings.sleep))
245
246
 
246
247
 
247
248
  @settings(kw_only=True)
@@ -287,6 +288,9 @@ class ForgetSettings:
287
288
  keep_within_yearly: str | None = option(
288
289
  default=SETTINGS.keep_within_yearly, help=_get_help(Settings.keep_within_yearly)
289
290
  )
291
+ group_by: list[str] | None = option(
292
+ default=SETTINGS.group_by, help=_get_help(Settings.group_by)
293
+ )
290
294
  prune: bool = option(default=SETTINGS.prune, help=_get_help(Settings.prune))
291
295
  repack_cacheable_only: bool = option(
292
296
  default=SETTINGS.repack_cacheable_only,
@@ -304,6 +308,23 @@ class ForgetSettings:
304
308
  )
305
309
 
306
310
 
311
+ @settings(kw_only=True)
312
+ class InitSettings:
313
+ password: Secret[str] = secret(
314
+ default=SETTINGS.password, help=_get_help(Settings.password)
315
+ )
316
+
317
+
318
+ @settings(kw_only=True)
319
+ class UnlockSettings:
320
+ password: Secret[str] = secret(
321
+ default=SETTINGS.password, help=_get_help(Settings.password)
322
+ )
323
+ remove_all: bool = option(
324
+ default=SETTINGS.remove_all, help=_get_help(Settings.remove_all)
325
+ )
326
+
327
+
307
328
  @settings(kw_only=True)
308
329
  class RestoreSettings:
309
330
  password: Secret[str] = secret(
@@ -346,4 +367,5 @@ __all__ = [
346
367
  "RestoreSettings",
347
368
  "Settings",
348
369
  "SnapshotsSettings",
370
+ "UnlockSettings",
349
371
  ]
restic/utilities.py CHANGED
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from contextlib import contextmanager
4
+ from dataclasses import dataclass
4
5
  from itertools import chain
5
6
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Literal, assert_never
7
+ from typing import TYPE_CHECKING, assert_never, override
7
8
 
8
9
  from typed_settings import Secret
9
10
  from utilities.os import temp_environ
10
- from utilities.subprocess import run
11
11
  from utilities.tempfile import TemporaryFile
12
+ from utilities.text import repr_str
12
13
 
13
14
  from restic.settings import SETTINGS
14
15
 
@@ -36,6 +37,10 @@ def expand_exclude_i(*, exclude_i: list[str] | None = None) -> list[str]:
36
37
  return _expand_list("iexclude", arg=exclude_i)
37
38
 
38
39
 
40
+ def expand_group_by(*, group_by: list[str] | None = None) -> list[str]:
41
+ return [] if group_by is None else ["--group-by", ",".join(group_by)]
42
+
43
+
39
44
  def expand_include(*, include: list[str] | None = None) -> list[str]:
40
45
  return _expand_list("include", arg=include)
41
46
 
@@ -52,12 +57,19 @@ def expand_keep_within(freq: str, /, *, duration: str | None = None) -> list[str
52
57
  return [] if duration is None else [f"--keep-{freq}", duration]
53
58
 
54
59
 
60
+ def expand_read_concurrency(n: int, /) -> list[str]:
61
+ return ["--read-concurrency", str(n)]
62
+
63
+
55
64
  def expand_tag(*, tag: list[str] | None = None) -> list[str]:
56
65
  return _expand_list("tag", arg=tag)
57
66
 
58
67
 
59
- def run_chmod(path: PathLike, type_: Literal["f", "d"], mode: str, /) -> None:
60
- run("sudo", "find", str(path), "-type", type_, "-exec", "chmod", mode, "{}", "+")
68
+ def expand_target(target: PathLike, /) -> list[str]:
69
+ return ["--target", str(target)]
70
+
71
+
72
+ ##
61
73
 
62
74
 
63
75
  @contextmanager
@@ -77,8 +89,7 @@ def yield_password(
77
89
  with temp_environ({env_var: str(value)}):
78
90
  yield
79
91
  else:
80
- msg = f"Password file not found: '{value!s}'"
81
- raise FileNotFoundError(msg)
92
+ raise YieldPasswordError(path=value)
82
93
  case str():
83
94
  if Path(value).is_file():
84
95
  with temp_environ({env_var: value}):
@@ -91,6 +102,18 @@ def yield_password(
91
102
  assert_never(never)
92
103
 
93
104
 
105
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
106
+ class YieldPasswordError(Exception):
107
+ path: Path
108
+
109
+ @override
110
+ def __str__(self) -> str:
111
+ return f"Password file not found: {repr_str(self.path)}"
112
+
113
+
114
+ ##
115
+
116
+
94
117
  def _expand_list(flag: str, /, *, arg: list[str] | None = None) -> list[str]:
95
118
  return (
96
119
  [] if arg is None else list(chain.from_iterable([f"--{flag}", a] for a in arg))
@@ -98,15 +121,18 @@ def _expand_list(flag: str, /, *, arg: list[str] | None = None) -> list[str]:
98
121
 
99
122
 
100
123
  __all__ = [
124
+ "YieldPasswordError",
101
125
  "expand_bool",
102
126
  "expand_dry_run",
103
127
  "expand_exclude",
104
128
  "expand_exclude_i",
129
+ "expand_group_by",
105
130
  "expand_include",
106
131
  "expand_include_i",
107
132
  "expand_keep",
108
133
  "expand_keep_within",
134
+ "expand_read_concurrency",
109
135
  "expand_tag",
110
- "run_chmod",
136
+ "expand_target",
111
137
  "yield_password",
112
138
  ]
@@ -1,15 +0,0 @@
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,,