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.
- dycw_restic-0.2.22.dist-info/METADATA +15 -0
- dycw_restic-0.2.22.dist-info/RECORD +15 -0
- dycw_restic-0.2.22.dist-info/WHEEL +4 -0
- dycw_restic-0.2.22.dist-info/entry_points.txt +3 -0
- restic/__init__.py +3 -0
- restic/cli.py +171 -0
- restic/click.py +51 -0
- restic/constants.py +36 -0
- restic/lib.py +289 -0
- restic/logging.py +8 -0
- restic/py.typed +0 -0
- restic/repo.py +123 -0
- restic/settings.py +349 -0
- restic/types.py +9 -0
- restic/utilities.py +112 -0
|
@@ -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,,
|
restic/__init__.py
ADDED
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
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
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
|
+
]
|