backupchan-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ build/
2
+ *.egg-info
3
+ *.swp
4
+ __pycache__/
5
+ dist/
@@ -0,0 +1,11 @@
1
+ Copyright 2025 moltony
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
+
11
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: backupchan-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for interacting with Backup-chan.
5
+ Author-email: Moltony <koronavirusnyj@gmail.com>
6
+ License: BSD-3-Clause
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: BSD License
10
+ Classifier: Natural Language :: English
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: System :: Archiving :: Backup
14
+ Classifier: Typing :: Typed
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: backupchan-client-lib
18
+ Requires-Dist: keyring
19
+ Requires-Dist: platformdirs
20
+ Dynamic: license-file
21
+
22
+ # Backup-chan CLI
23
+
24
+ This is the command-line interface program for interacting with a Backup-chan server.
25
+
26
+ ## Installing
27
+
28
+ ```bash
29
+ pip install .
30
+ ```
31
+
32
+ You can also run right from source if you don't feel like installing the program. In
33
+ this case, you'll have to install dependencies manually.
34
+
35
+ ```bash
36
+ ./cli.py
37
+ ```
38
+
39
+ ## Configuring
40
+
41
+ The CLI has to first be configured before you can use it. Run:
42
+
43
+ ```bash
44
+ # Interactive configuration.
45
+ backupchan config new -i
46
+
47
+ # Non-interactive configuration.
48
+ backupchan config new -H "http://host" -p 5050 -a "your api key"
49
+ ```
50
+
51
+ Run `backupchan --help` to see all the commands you can use.
@@ -0,0 +1,30 @@
1
+ # Backup-chan CLI
2
+
3
+ This is the command-line interface program for interacting with a Backup-chan server.
4
+
5
+ ## Installing
6
+
7
+ ```bash
8
+ pip install .
9
+ ```
10
+
11
+ You can also run right from source if you don't feel like installing the program. In
12
+ this case, you'll have to install dependencies manually.
13
+
14
+ ```bash
15
+ ./cli.py
16
+ ```
17
+
18
+ ## Configuring
19
+
20
+ The CLI has to first be configured before you can use it. Run:
21
+
22
+ ```bash
23
+ # Interactive configuration.
24
+ backupchan config new -i
25
+
26
+ # Non-interactive configuration.
27
+ backupchan config new -H "http://host" -p 5050 -a "your api key"
28
+ ```
29
+
30
+ Run `backupchan --help` to see all the commands you can use.
@@ -0,0 +1 @@
1
+ # so like
@@ -0,0 +1,151 @@
1
+ import requests.exceptions
2
+ import os
3
+ import sys
4
+ import tarfile
5
+ import tempfile
6
+ import uuid
7
+ from backupchan_cli import utility
8
+ from backupchan import API, BackupchanAPIError, Backup, BackupType
9
+
10
+ #
11
+ # Utilities
12
+ #
13
+
14
+ def print_backup(backup: Backup, spaces: str, show_recycled: bool, index: int):
15
+ print(f" {index + 1}. | ID: {backup.id}")
16
+ print(f" {spaces} | Created at: {backup.pretty_created_at()}")
17
+ if show_recycled:
18
+ print(f" {spaces} | Recycled: {'Yes' if backup.is_recycled else 'No'}")
19
+ print(f" {spaces} | Size: {utility.humanread_file_size(backup.filesize)}")
20
+ if backup.manual:
21
+ print(f" {spaces} | Uploaded manually")
22
+ else:
23
+ print(f" {spaces} | Uploaded automatically")
24
+ print("=========")
25
+
26
+ #
27
+ #
28
+ #
29
+
30
+ def setup_subcommands(subparser):
31
+ #
32
+ #
33
+ #
34
+
35
+ upload_cmd = subparser.add_parser("upload", help="Upload a backup")
36
+ # For things like cron jobs etc. using the cli
37
+ upload_cmd.add_argument("--automatic", "-a", action="store_true", help="Mark backup as having been added automatically")
38
+ upload_cmd.add_argument("target_id", type=str, help="ID of the target to upload backup to")
39
+ upload_cmd.add_argument("filename", type=str, help="Name of the file to upload")
40
+ upload_cmd.set_defaults(func=do_upload)
41
+
42
+ #
43
+ #
44
+ #
45
+
46
+ delete_cmd = subparser.add_parser("delete", help="Delete an existing backup")
47
+ delete_cmd.add_argument("id", type=str, help="ID of the backup to delete")
48
+ delete_cmd.add_argument("--delete-files", "-d", action="store_true", help="Delete backup files as well")
49
+ delete_cmd.set_defaults(func=do_delete)
50
+
51
+ #
52
+ #
53
+ #
54
+
55
+ recycle_cmd = subparser.add_parser("recycle", help="Recycle an existing backup")
56
+ recycle_cmd.add_argument("id", type=str, help="ID of the backup to recycle")
57
+ recycle_cmd.set_defaults(func=do_recycle)
58
+
59
+ #
60
+ #
61
+ #
62
+
63
+ restore_cmd = subparser.add_parser("restore", help="Restore an existing backup")
64
+ restore_cmd.add_argument("id", type=str, help="ID of the backup to restore")
65
+ restore_cmd.set_defaults(func=do_restore)
66
+
67
+ #
68
+ # backupchan backup upload
69
+ #
70
+
71
+ def do_upload(args, _, api: API):
72
+ if os.path.isdir(args.filename):
73
+ # Cannot upload a directory to a single-file target.
74
+ target_type = api.get_target(args.target_id)[0].target_type
75
+ if target_type == BackupType.SINGLE:
76
+ utility.failure("Cannot upload directory to a single file target")
77
+
78
+ # Make a temporary gzipped tarball containing the directory contents.
79
+ temp_dir = tempfile.gettempdir()
80
+ temp_tar_path = os.path.join(temp_dir, f"bakch-{uuid.uuid4().hex}.tar.gz")
81
+ with tarfile.open(temp_tar_path, "w:gz") as tar:
82
+ tar.add(args.filename, arcname=os.path.basename(args.filename))
83
+
84
+ # Upload our new tar.
85
+ with open(temp_tar_path, "rb") as tar:
86
+ try:
87
+ api.upload_backup(args.target_id, tar, os.path.basename(args.filename) + ".tar.gz", not args.automatic)
88
+ except requests.exceptions.ConnectionError:
89
+ utility.failure_network()
90
+ except BackupchanAPIError as exc:
91
+ utility.failure(f"Failed to upload backup: {str(exc)}")
92
+
93
+ else:
94
+ with open(args.filename, "rb") as file:
95
+ try:
96
+ api.upload_backup(args.target_id, file, os.path.basename(args.filename), not args.automatic)
97
+ except requests.exceptions.ConnectionError:
98
+ utility.failure_network()
99
+ except BackupchanAPIError as exc:
100
+ utility.failure(f"Failed to upload backup: {str(exc)}")
101
+ print("Backup uploaded.")
102
+
103
+ #
104
+ # backupchan backup delete
105
+ #
106
+
107
+ def do_delete(args, _, api: API):
108
+ delete_files = args.delete_files
109
+
110
+ try:
111
+ api.delete_backup(args.id, delete_files)
112
+ except requests.exceptions.ConnectionError:
113
+ utility.failure_network()
114
+ except BackupchanAPIError as exc:
115
+ if exc.status_code == 404:
116
+ utility.failure("Backup not found")
117
+ utility.failure(f"Failed to delete backup: {str(exc)}")
118
+
119
+ print("Backup deleted.")
120
+
121
+ #
122
+ # backupchan backup recycle
123
+ #
124
+
125
+ def do_recycle(args, _, api: API):
126
+ try:
127
+ api.recycle_backup(args.id, True)
128
+ except requests.exceptions.ConnectionError:
129
+ utility.failure_network()
130
+ except BackupchanAPIError as exc:
131
+ if exc.status_code == 404:
132
+ utility.failure("Backup not found")
133
+ utility.failure(f"Failed to recycle backup: {str(exc)}")
134
+
135
+ print("Backup recycled.")
136
+
137
+ #
138
+ # backupchan backup restore
139
+ #
140
+
141
+ def do_restore(args, _, api: API):
142
+ try:
143
+ api.recycle_backup(args.id, False)
144
+ except requests.exceptions.ConnectionError:
145
+ utility.failure_network()
146
+ except BackupchanAPIError as exc:
147
+ if exc.status_code == 404:
148
+ utility.failure("Backup not found")
149
+ utility.failure(f"Failed to restore backup: {str(exc)}")
150
+
151
+ print("Backup restored.")
@@ -0,0 +1,80 @@
1
+ import os
2
+ from backupchan_cli.config import Config
3
+ from backupchan_cli import utility
4
+
5
+ #
6
+ #
7
+ #
8
+
9
+ def setup_subcommands(subparser):
10
+ new_cmd = subparser.add_parser("new", help="Create a new config")
11
+ new_cmd.add_argument("--interactive", "-i", action="store_true", help="Make a new config interactively (no need for other options)")
12
+ new_cmd.add_argument("--host", "-H", type=str, help="Hostname of Backup-chan server")
13
+ new_cmd.add_argument("--port", "-p", type=int, help="Port of Backup-chan server")
14
+ new_cmd.add_argument("--api-key", "-a", type=str, help="Backup-chan API key")
15
+ new_cmd.set_defaults(func=do_new)
16
+
17
+ view_cmd = subparser.add_parser("view", help="View current configuration")
18
+ view_cmd.set_defaults(func=do_view)
19
+
20
+ reset_cmd = subparser.add_parser("reset", help="Reset current configuration")
21
+ reset_cmd.set_defaults(func=do_reset)
22
+
23
+ #
24
+ # backupchan config new
25
+ #
26
+
27
+ def do_new(args, config: Config, _):
28
+ if args.interactive:
29
+ if has_argument_new_args(args):
30
+ utility.failure("Do not pass new config values as arguments when running interactively")
31
+ interactive_new_config(config)
32
+ else:
33
+ if not has_argument_new_args(args):
34
+ utility.failure("Host, port or api key argument not passed, or run interactively")
35
+ argument_new_config(args, config)
36
+
37
+ def has_argument_new_args(args) -> bool:
38
+ return args.host or args.port or args.api_key
39
+
40
+ def interactive_new_config(config: Config):
41
+ host = input("Host: ").strip().lower()
42
+ port = input("Port: ").strip().lower()
43
+ if not utility.is_parsable_int(port):
44
+ utility.failure("Port must be an integer number")
45
+ api_key = input("API key (leave blank if auth is disabled): ").strip()
46
+
47
+ config.host = host
48
+ config.port = port
49
+ config.api_key = api_key
50
+ config.save_config()
51
+
52
+ print("Configuration saved.")
53
+
54
+ def argument_new_config(args, config: Config):
55
+ config.host = args.host
56
+ config.port = args.port
57
+ config.api_key = args.api_key
58
+ config.save_config()
59
+
60
+ print("Configuration saved.")
61
+
62
+ #
63
+ # backupchan config view
64
+ #
65
+
66
+ def do_view(args, config: Config, _):
67
+ if config.is_incomplete():
68
+ utility.failure(utility.NO_CONFIG_MESSAGE)
69
+
70
+ print(f"Host: {config.host}")
71
+ print(f"Port: {config.port}")
72
+ print(f"API key: {config.api_key}")
73
+
74
+ #
75
+ # backupchan config reset
76
+ #
77
+
78
+ def do_reset(args, config: Config, _):
79
+ config.reset(True)
80
+ print("Configuration reset.")
@@ -0,0 +1,17 @@
1
+ from backupchan import API
2
+
3
+ #
4
+ #
5
+ #
6
+
7
+ def setup_subcommands(subparser):
8
+ view_cmd = subparser.add_parser("view", help="View log")
9
+ view_cmd.add_argument("--tail", "-t", type=int, help="Trim log to show this many last lines", default=0)
10
+ view_cmd.set_defaults(func=do_view)
11
+
12
+ #
13
+ # backupchan log view
14
+ #
15
+
16
+ def do_view(args, _, api: API):
17
+ print(api.get_log(args.tail))
@@ -0,0 +1,41 @@
1
+ from backupchan import API
2
+ from .backup import print_backup
3
+ from backupchan_cli import utility
4
+
5
+ #
6
+ #
7
+ #
8
+
9
+ def setup_subcommands(subparser):
10
+ view_cmd = subparser.add_parser("view", help="View recycle bin")
11
+ view_cmd.set_defaults(func=do_view)
12
+
13
+ clear_cmd = subparser.add_parser("clear", help="Clear recycle bin")
14
+ clear_cmd.add_argument("--delete-files", "-d", action="store_true", help="Delete backup files as well")
15
+ clear_cmd.set_defaults(func=do_clear)
16
+
17
+ #
18
+ # backupchan recyclebin view
19
+ #
20
+
21
+ def do_view(args, _, api: API):
22
+ try:
23
+ backups = api.list_recycled_backups()
24
+ except requests.exceptions.ConnectionError:
25
+ utility.failure_network()
26
+
27
+ for index, backup in enumerate(backups):
28
+ spaces = " " * (len(str(index + 1)) + 1)
29
+ print_backup(backup, spaces, False, index)
30
+
31
+ #
32
+ # backupchan recyclebin clear
33
+ #
34
+
35
+ def do_clear(args, _, api: API):
36
+ try:
37
+ api.clear_recycle_bin(args.delete_files)
38
+ except requests.exceptions.ConnectionError:
39
+ utility.failure_network()
40
+
41
+ print("Recycle bin cleared.")
@@ -0,0 +1,273 @@
1
+ import requests
2
+ from backupchan_cli import utility
3
+ from .backup import print_backup
4
+ from backupchan import API, BackupType, BackupRecycleCriteria, BackupTarget, BackupRecycleAction, BackupchanAPIError
5
+
6
+ #
7
+ # Utilities
8
+ #
9
+
10
+ def print_target(target: BackupTarget, spaces: str | None, index: int):
11
+ prefix = "" if spaces is None else f" {spaces} | "
12
+ if spaces is None:
13
+ print(f"Name: {target.name}")
14
+ else:
15
+ print(f" {index + 1}. | {target.name}")
16
+ if target.alias is not None:
17
+ print(f"{prefix}Alias: {target.alias}")
18
+ print(f"{prefix}ID: {target.id}")
19
+ print(f"{prefix}Type: {HR_TYPES[target.target_type]}")
20
+ print(f"{prefix}Recycle criteria: {hr_recycle_criteria(target)}")
21
+ if target.recycle_criteria != BackupRecycleCriteria.NONE:
22
+ print(f"{prefix}Recycle action: {HR_RECYCLE_ACTIONS[target.recycle_action]}")
23
+ print(f"{prefix}Location: {target.location}")
24
+ print(f"{prefix}Name template: {target.name_template}")
25
+ print(f"{prefix}Deduplication {'on' if target.deduplicate else 'off'}")
26
+ print("=========")
27
+
28
+ #
29
+ #
30
+ #
31
+
32
+
33
+ def setup_subcommands(subparser):
34
+ #
35
+ #
36
+ #
37
+
38
+ list_cmd = subparser.add_parser("list", help="List all targets")
39
+ list_cmd.add_argument("--page", "-p", type=int, default=1, help="Page in the list")
40
+ list_cmd.set_defaults(func=do_list)
41
+
42
+ #
43
+ #
44
+ #
45
+
46
+ view_cmd = subparser.add_parser("view", help="View a specific target")
47
+ view_cmd.add_argument("id", type=str, help="ID of the target to view")
48
+ view_cmd.add_argument("--include-recycled", "-r", action="store_true", help="Include recycled backups too")
49
+ view_cmd.set_defaults(func=do_view)
50
+
51
+ #
52
+ #
53
+ #
54
+
55
+ new_cmd = subparser.add_parser("new", help="Create a new target")
56
+ new_cmd.add_argument("--name", "-n", type=str, help="Name of the new target")
57
+ new_cmd.add_argument("--type", "-t", type=lambda t: BackupType(t), choices=list(BackupType), help="Type of the new target")
58
+ new_cmd.add_argument("--recycle-criteria", "-c", type=lambda c: BackupRecycleCriteria(c), choices=list(BackupRecycleCriteria), help="Recycle criteria")
59
+ new_cmd.add_argument("--recycle-value", "-v", type=int, help="Recycle value (optional if criteria is none)")
60
+ new_cmd.add_argument("--recycle-action", "-a", type=lambda a: BackupRecycleAction(a), choices=list(BackupRecycleAction), help="Recycle action")
61
+ new_cmd.add_argument("--location", "-l", type=str, help="Location of the new target")
62
+ new_cmd.add_argument("--name-template", "-m", type=str, help="Name template for backups. Must include either $I or $D, or both.")
63
+ new_cmd.add_argument("--deduplicate", "-d", action="store_true", help="(optional) Enable deduplication")
64
+ new_cmd.add_argument("--alias", type=str, help="(optional) Target alias. It can be used as the ID.")
65
+ new_cmd.set_defaults(func=do_new)
66
+
67
+ #
68
+ #
69
+ #
70
+
71
+ delete_cmd = subparser.add_parser("delete", help="Delete an existing target")
72
+ delete_cmd.add_argument("id", type=str, help="ID of the target to delete")
73
+ delete_cmd.add_argument("--delete-files", "-d", action="store_true", help="Delete backup files as well")
74
+ delete_cmd.set_defaults(func=do_delete)
75
+
76
+ #
77
+ #
78
+ #
79
+
80
+ edit_cmd = subparser.add_parser("edit", help="Edit an existing target")
81
+ edit_cmd.add_argument("id", type=str, help="ID of the target to edit")
82
+ edit_cmd.add_argument("--name", "-n", type=str, help="New name of the target")
83
+ edit_cmd.add_argument("--recycle-criteria", "-c", type=lambda c: BackupRecycleCriteria(c), choices=list(BackupRecycleCriteria), help="New recycle criteria")
84
+ edit_cmd.add_argument("--recycle-value", "-v", type=int, help="New recycle value")
85
+ edit_cmd.add_argument("--recycle-action", "-a", type=lambda a: BackupRecycleAction(a), choices=list(BackupRecycleAction), help="New recycle action")
86
+ edit_cmd.add_argument("--location", "-l", type=str, help="New location of the target")
87
+ edit_cmd.add_argument("--name-template", "-m", type=str, help="New name template of the target")
88
+ edit_cmd.add_argument("--toggle-deduplication", "-d", action="store_true", help="Toggle target deduplication")
89
+ edit_cmd.add_argument("--alias", type=str, help="Target alias")
90
+ edit_cmd.add_argument("--remove-alias", action="store_true", help="Remove alias from the target if it has one")
91
+ edit_cmd.set_defaults(func=do_edit)
92
+
93
+ #
94
+ #
95
+ #
96
+
97
+ delete_backups_cmd = subparser.add_parser("deletebackups", help="Delete all backups of a target")
98
+ delete_backups_cmd.add_argument("id", type=str, help="ID of the target to delete backups of")
99
+ delete_backups_cmd.add_argument("--delete-files", "-d", action="store_true", help="Delete backup files as well")
100
+ delete_backups_cmd.set_defaults(func=do_delete_backups)
101
+
102
+ #
103
+ # Value to human-readable string conversions and lookup tables
104
+ #
105
+
106
+ HR_TYPES = {
107
+ BackupType.SINGLE: "Single file",
108
+ BackupType.MULTI: "Multiple files"
109
+ }
110
+
111
+ HR_RECYCLE_ACTIONS = {
112
+ BackupRecycleAction.DELETE: "Delete",
113
+ BackupRecycleAction.RECYCLE: "Recycle"
114
+ }
115
+
116
+ def hr_recycle_criteria(target: BackupTarget) -> str:
117
+ if target.recycle_criteria == BackupRecycleCriteria.NONE:
118
+ return "None"
119
+ elif target.recycle_criteria == BackupRecycleCriteria.AGE:
120
+ return f"After {target.recycle_value} days"
121
+ elif target.recycle_criteria == BackupRecycleCriteria.COUNT:
122
+ return f"After {target.recycle_value} copies"
123
+ return "(broken value)"
124
+
125
+ #
126
+ # backupchan target list
127
+ #
128
+
129
+ def do_list(args, _, api: API):
130
+ try:
131
+ targets = api.list_targets(args.page)
132
+ except requests.exceptions.ConnectionError:
133
+ utility.failure_network()
134
+
135
+ print(f"Showing page {args.page}\n")
136
+
137
+ if not targets:
138
+ print("There are no targets.")
139
+ return
140
+
141
+ for index, target in enumerate(targets):
142
+ spaces = " " * (len(str(index + 1)) + 1)
143
+ print_target(target, spaces, index)
144
+
145
+ #
146
+ # backupchan target view
147
+ #
148
+
149
+ # TODO is it necessary to pass config to every subcommand?
150
+ def do_view(args, _, api: API):
151
+ try:
152
+ target, backups = api.get_target(args.id)
153
+ except requests.exceptions.ConnectionError:
154
+ utility.failure_network()
155
+ except BackupchanAPIError as exc:
156
+ if exc.status_code == 404:
157
+ utility.failure("Target not found")
158
+ raise
159
+
160
+ print_target(target, None, 0)
161
+
162
+ if len(backups) == 0:
163
+ print("This target has no backups.")
164
+ return
165
+ elif args.include_recycled:
166
+ print("Backups:")
167
+ else:
168
+ print("Backups (pass -r to view recycled ones too):")
169
+ print()
170
+
171
+ if not args.include_recycled:
172
+ backups = [backup for backup in backups if not backup.is_recycled]
173
+
174
+ for index, backup in enumerate(backups):
175
+ spaces = " " * (len(str(index + 1)) + 1)
176
+ print_backup(backup, spaces, args.include_recycled, index)
177
+
178
+ #
179
+ # backupchan target new
180
+ #
181
+
182
+ def do_new(args, _, api: API):
183
+ utility.required_args(args, "name", "type", "recycle_criteria", "location", "name_template")
184
+
185
+ name = args.name
186
+ target_type = args.type
187
+ recycle_criteria = args.recycle_criteria
188
+ location = args.location
189
+ name_template = args.name_template
190
+ deduplicate = args.deduplicate
191
+ alias = args.alias
192
+
193
+ recycle_value = 0
194
+ recycle_action = BackupRecycleAction.RECYCLE
195
+ if args.recycle_criteria != BackupRecycleCriteria.NONE:
196
+ utility.required_args(args, "recycle_value", "recycle_action")
197
+ recycle_value = args.recycle_value
198
+ recycle_action = args.recycle_action
199
+
200
+ try:
201
+ target_id = api.new_target(name, target_type, recycle_criteria, recycle_value, recycle_action, location, name_template, deduplicate, alias)
202
+ except requests.exceptions.ConnectionError:
203
+ utility.failure_network()
204
+ except BackupchanAPIError as exc:
205
+ utility.failure(f"Failed to create new target: {str(exc)}")
206
+
207
+ print(f"Created new target. ID: {target_id}")
208
+
209
+ #
210
+ # backupchan target delete
211
+ #
212
+
213
+ def do_delete(args, _, api: API):
214
+ delete_files = args.delete_files
215
+
216
+ try:
217
+ api.delete_target(args.id, delete_files)
218
+ except requests.exceptions.ConnectionError:
219
+ utility.failure_network()
220
+ except BackupchanAPIError as exc:
221
+ utility.failure(f"Failed to delete target: {str(exc)}")
222
+ print("Target deleted.")
223
+
224
+ #
225
+ # backupchan target edit
226
+ #
227
+
228
+ def do_edit(args, _, api: API):
229
+ target_id = args.id
230
+
231
+ try:
232
+ target = api.get_target(target_id)[0]
233
+ except requests.exceptions.ConnectionError:
234
+ utility.failure_network()
235
+ except BackupchanAPIError as exc:
236
+ if exc.status_code == 404:
237
+ utility.failure("Target not found")
238
+ raise
239
+
240
+ name = args.name or target.name
241
+ recycle_criteria = args.recycle_criteria or target.recycle_criteria
242
+ recycle_value = args.recycle_value or target.recycle_value
243
+ recycle_action = args.recycle_action or target.recycle_action
244
+ location = args.location or target.location
245
+ name_template = args.name_template or target.name_template
246
+ deduplicate = target.deduplicate
247
+ if args.toggle_deduplication:
248
+ deduplicate = not deduplicate
249
+ alias = (None if args.remove_alias else args.alias) or target.alias
250
+
251
+ try:
252
+ api.edit_target(target_id, name, recycle_criteria, recycle_value, recycle_action, location, name_template, deduplicate, alias)
253
+ except requests.exceptions.ConnectionError:
254
+ utility.failure_network()
255
+ except BackupchanAPIError as exc:
256
+ utility.failure(f"Failed to edit target: {str(exc)}")
257
+
258
+ print("Target edited.")
259
+
260
+ #
261
+ # backupchan target deletebackups
262
+ #
263
+
264
+ def do_delete_backups(args, _, api: API):
265
+ delete_files = args.delete_files
266
+
267
+ try:
268
+ api.delete_target_backups(args.id, delete_files)
269
+ except requests.exceptions.ConnectionError:
270
+ utility.failure_network()
271
+ except BackupchanAPIError as exc:
272
+ utility.failure(f"Failed to delete target backups: {str(exc)}")
273
+ print("Target backups deleted.")
@@ -0,0 +1,72 @@
1
+ import json
2
+ import sys
3
+ import platformdirs
4
+ import keyring
5
+ import os
6
+ from pathlib import Path
7
+
8
+ class ConfigException(Exception):
9
+ pass
10
+
11
+ CONFIG_FILE_DIR = platformdirs.user_config_dir('backupchan')
12
+ CONFIG_FILE_PATH = f"{CONFIG_FILE_DIR}/config.json"
13
+
14
+ class Config:
15
+ def __init__(self):
16
+ self.port: int | None = None
17
+ self.host: str | None = None
18
+ self.api_key: str | None = None
19
+
20
+ def read_config(self):
21
+ if not os.path.exists(CONFIG_FILE_PATH):
22
+ raise ConfigException("Config file not found")
23
+
24
+ with open(CONFIG_FILE_PATH, "r") as config_file:
25
+ self.parse_config(config_file.read())
26
+ self.retrieve_api_key()
27
+
28
+ def reset(self, write: bool = False):
29
+ self.port = None
30
+ self.host = None
31
+ self.api_key = None
32
+
33
+ if write:
34
+ self.delete_api_key()
35
+ if os.path.exists(CONFIG_FILE_PATH):
36
+ os.remove(CONFIG_FILE_PATH)
37
+
38
+ def is_incomplete(self):
39
+ return self.port is None or self.host is None or self.api_key is None
40
+
41
+ def parse_config(self, config: str):
42
+ config_json = json.loads(config)
43
+ self.port = config_json["port"]
44
+ self.host = config_json["host"]
45
+
46
+ def retrieve_api_key(self):
47
+ self.api_key = keyring.get_password("backupchan", "api_key")
48
+
49
+ def save_config(self):
50
+ if self.is_incomplete():
51
+ raise ConfigException("Cannot save incomplete config")
52
+
53
+ Path(CONFIG_FILE_DIR).mkdir(exist_ok=True, parents=True)
54
+
55
+ config_dict = {
56
+ "host": self.host,
57
+ "port": self.port
58
+ }
59
+
60
+ with open(CONFIG_FILE_PATH, "w") as config_file:
61
+ json.dump(config_dict, config_file)
62
+
63
+ self.save_api_key()
64
+
65
+ def delete_api_key(self):
66
+ try:
67
+ keyring.delete_password("backupchan", "api_key")
68
+ except keyring.errors.PasswordDeleteError:
69
+ pass
70
+
71
+ def save_api_key(self):
72
+ keyring.set_password("backupchan", "api_key", self.api_key)
@@ -0,0 +1,49 @@
1
+ from .commands import config, target, backup, log, recyclebin
2
+ from .config import Config, ConfigException
3
+ from .utility import failure, NO_CONFIG_MESSAGE
4
+ from backupchan import API
5
+ import argparse
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(prog="backupchan")
9
+ subparsers = parser.add_subparsers(dest="command")
10
+
11
+ config_parser = subparsers.add_parser("config")
12
+ config_sub = config_parser.add_subparsers(dest="subcommand", help="View and edit configuration")
13
+ config.setup_subcommands(config_sub)
14
+
15
+ target_parser = subparsers.add_parser("target")
16
+ target_sub = target_parser.add_subparsers(dest="subcommand", help="View and manage targets")
17
+ target.setup_subcommands(target_sub)
18
+
19
+ backup_parser = subparsers.add_parser("backup")
20
+ backup_sub = backup_parser.add_subparsers(dest="subcommand", help="Add and manage backups")
21
+ backup.setup_subcommands(backup_sub)
22
+
23
+ log_parser = subparsers.add_parser("log")
24
+ log_sub = log_parser.add_subparsers(dest="subcommand", help="View the execution log")
25
+ log.setup_subcommands(log_sub)
26
+
27
+ recycle_bin_parser = subparsers.add_parser("recyclebin")
28
+ recycle_bin_sub = recycle_bin_parser.add_subparsers(dest="subcommand", help="View and clear the recycle bin")
29
+ recyclebin.setup_subcommands(recycle_bin_sub)
30
+
31
+ app_config = Config()
32
+ try:
33
+ app_config.read_config()
34
+ except ConfigException:
35
+ app_config.reset()
36
+ pass
37
+
38
+ api = None if app_config.is_incomplete() else API(app_config.host, app_config.port, app_config.api_key)
39
+
40
+ args = parser.parse_args()
41
+ if hasattr(args, "func"):
42
+ if args.command != "config" and app_config.is_incomplete():
43
+ failure(NO_CONFIG_MESSAGE)
44
+
45
+ args.func(args, app_config, api)
46
+ else:
47
+ parser.print_help()
48
+
49
+ return 0
@@ -0,0 +1,33 @@
1
+ import sys
2
+
3
+ NO_CONFIG_MESSAGE = "No configuration. Run `backupchan config new' to configure."
4
+
5
+ def is_parsable_int(number_str: str) -> bool:
6
+ try:
7
+ int(number_str)
8
+ return True
9
+ except ValueError:
10
+ return False
11
+
12
+ def failure(message: str):
13
+ print(f"{message}. Halting.", file=sys.stderr)
14
+ sys.exit(1)
15
+
16
+ def failure_network():
17
+ failure("Failed to connect to server")
18
+
19
+ SIZE_UNITS = [
20
+ "B", "KiB", "MiB", "GiB", "TiB"
21
+ ]
22
+
23
+ def humanread_file_size(size: float):
24
+ i = 0
25
+ while size > 1024:
26
+ size /= 1024
27
+ i += 1
28
+ return f"{size:.2f} {SIZE_UNITS[i]}"
29
+
30
+ def required_args(args_object, *args):
31
+ for arg in args:
32
+ if not getattr(args_object, arg):
33
+ failure(f"Argument '{arg}' is required")
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: backupchan-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for interacting with Backup-chan.
5
+ Author-email: Moltony <koronavirusnyj@gmail.com>
6
+ License: BSD-3-Clause
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: BSD License
10
+ Classifier: Natural Language :: English
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: System :: Archiving :: Backup
14
+ Classifier: Typing :: Typed
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: backupchan-client-lib
18
+ Requires-Dist: keyring
19
+ Requires-Dist: platformdirs
20
+ Dynamic: license-file
21
+
22
+ # Backup-chan CLI
23
+
24
+ This is the command-line interface program for interacting with a Backup-chan server.
25
+
26
+ ## Installing
27
+
28
+ ```bash
29
+ pip install .
30
+ ```
31
+
32
+ You can also run right from source if you don't feel like installing the program. In
33
+ this case, you'll have to install dependencies manually.
34
+
35
+ ```bash
36
+ ./cli.py
37
+ ```
38
+
39
+ ## Configuring
40
+
41
+ The CLI has to first be configured before you can use it. Run:
42
+
43
+ ```bash
44
+ # Interactive configuration.
45
+ backupchan config new -i
46
+
47
+ # Non-interactive configuration.
48
+ backupchan config new -H "http://host" -p 5050 -a "your api key"
49
+ ```
50
+
51
+ Run `backupchan --help` to see all the commands you can use.
@@ -0,0 +1,20 @@
1
+ .gitignore
2
+ LICENSE
3
+ README.md
4
+ cli.py
5
+ pyproject.toml
6
+ backupchan_cli/__init__.py
7
+ backupchan_cli/config.py
8
+ backupchan_cli/main.py
9
+ backupchan_cli/utility.py
10
+ backupchan_cli.egg-info/PKG-INFO
11
+ backupchan_cli.egg-info/SOURCES.txt
12
+ backupchan_cli.egg-info/dependency_links.txt
13
+ backupchan_cli.egg-info/entry_points.txt
14
+ backupchan_cli.egg-info/requires.txt
15
+ backupchan_cli.egg-info/top_level.txt
16
+ backupchan_cli/commands/backup.py
17
+ backupchan_cli/commands/config.py
18
+ backupchan_cli/commands/log.py
19
+ backupchan_cli/commands/recyclebin.py
20
+ backupchan_cli/commands/target.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ backupchan = backupchan_cli.main:main
@@ -0,0 +1,3 @@
1
+ backupchan-client-lib
2
+ keyring
3
+ platformdirs
@@ -0,0 +1 @@
1
+ backupchan_cli
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/python3
2
+
3
+ from backupchan_cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "backupchan-cli"
3
+ version = "0.1.0"
4
+ description = "Command-line interface for interacting with Backup-chan."
5
+ authors = [
6
+ { name = "Moltony", email = "koronavirusnyj@gmail.com" }
7
+ ]
8
+ dependencies = [
9
+ "backupchan-client-lib",
10
+ "keyring",
11
+ "platformdirs"
12
+ ]
13
+
14
+ readme = "README.md"
15
+ license = {text = "BSD-3-Clause"}
16
+
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "License :: OSI Approved :: BSD License",
21
+ "Natural Language :: English",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Topic :: System :: Archiving :: Backup",
25
+ "Typing :: Typed"
26
+ ]
27
+
28
+ [project.scripts]
29
+ backupchan = "backupchan_cli.main:main"
30
+
31
+ [build-system]
32
+ requires = ["setuptools", "wheel"]
33
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+