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.
- backupchan_cli-0.1.0/.gitignore +5 -0
- backupchan_cli-0.1.0/LICENSE +11 -0
- backupchan_cli-0.1.0/PKG-INFO +51 -0
- backupchan_cli-0.1.0/README.md +30 -0
- backupchan_cli-0.1.0/backupchan_cli/__init__.py +1 -0
- backupchan_cli-0.1.0/backupchan_cli/commands/backup.py +151 -0
- backupchan_cli-0.1.0/backupchan_cli/commands/config.py +80 -0
- backupchan_cli-0.1.0/backupchan_cli/commands/log.py +17 -0
- backupchan_cli-0.1.0/backupchan_cli/commands/recyclebin.py +41 -0
- backupchan_cli-0.1.0/backupchan_cli/commands/target.py +273 -0
- backupchan_cli-0.1.0/backupchan_cli/config.py +72 -0
- backupchan_cli-0.1.0/backupchan_cli/main.py +49 -0
- backupchan_cli-0.1.0/backupchan_cli/utility.py +33 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/PKG-INFO +51 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/SOURCES.txt +20 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/dependency_links.txt +1 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/entry_points.txt +2 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/requires.txt +3 -0
- backupchan_cli-0.1.0/backupchan_cli.egg-info/top_level.txt +1 -0
- backupchan_cli-0.1.0/cli.py +6 -0
- backupchan_cli-0.1.0/pyproject.toml +33 -0
- backupchan_cli-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backupchan_cli
|
|
@@ -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"
|