backupchan-client-lib 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_client_lib-0.1.0/.gitignore +6 -0
- backupchan_client_lib-0.1.0/LICENSE +11 -0
- backupchan_client_lib-0.1.0/PKG-INFO +69 -0
- backupchan_client_lib-0.1.0/README.md +46 -0
- backupchan_client_lib-0.1.0/backupchan/__init__.py +5 -0
- backupchan_client_lib-0.1.0/backupchan/api.py +121 -0
- backupchan_client_lib-0.1.0/backupchan/connection.py +49 -0
- backupchan_client_lib-0.1.0/backupchan/models.py +50 -0
- backupchan_client_lib-0.1.0/backupchan_client_lib.egg-info/PKG-INFO +69 -0
- backupchan_client_lib-0.1.0/backupchan_client_lib.egg-info/SOURCES.txt +16 -0
- backupchan_client_lib-0.1.0/backupchan_client_lib.egg-info/dependency_links.txt +1 -0
- backupchan_client_lib-0.1.0/backupchan_client_lib.egg-info/requires.txt +5 -0
- backupchan_client_lib-0.1.0/backupchan_client_lib.egg-info/top_level.txt +1 -0
- backupchan_client_lib-0.1.0/pyproject.toml +35 -0
- backupchan_client_lib-0.1.0/setup.cfg +4 -0
- backupchan_client_lib-0.1.0/tests/conftest.py +8 -0
- backupchan_client_lib-0.1.0/tests/test_connection.py +144 -0
- backupchan_client_lib-0.1.0/tests/test_models.py +47 -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,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backupchan-client-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Library for interfacing 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: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: requests-mock; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Backup-chan client library
|
|
25
|
+
|
|
26
|
+
This is the Python library for interfacing with a Backup-chan server.
|
|
27
|
+
|
|
28
|
+
## Installing
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Normal install:
|
|
32
|
+
pip install .
|
|
33
|
+
# With extra dependencies for development:
|
|
34
|
+
pip install .[dev]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For instructions on setting up the server, refer to Backup-chan server's README.
|
|
38
|
+
|
|
39
|
+
## Testing
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Example
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from backupchan import *
|
|
49
|
+
|
|
50
|
+
api = API("http://192.168.1.43", 5000, "your api key")
|
|
51
|
+
|
|
52
|
+
targets = api.list_targets()
|
|
53
|
+
for target in targets:
|
|
54
|
+
print(target)
|
|
55
|
+
|
|
56
|
+
target_id = api.new_target(
|
|
57
|
+
"the waifu collection",
|
|
58
|
+
BackupType.MULTI,
|
|
59
|
+
BackupRecycleCriteria.AGE,
|
|
60
|
+
10,
|
|
61
|
+
BackupRecycleAction.RECYCLE,
|
|
62
|
+
"/var/backups/waifu",
|
|
63
|
+
"wf-$I_$D",
|
|
64
|
+
False,
|
|
65
|
+
None
|
|
66
|
+
)
|
|
67
|
+
target = api.get_target(target_id)
|
|
68
|
+
print(f"Created new target: {target}")
|
|
69
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Backup-chan client library
|
|
2
|
+
|
|
3
|
+
This is the Python library for interfacing with a Backup-chan server.
|
|
4
|
+
|
|
5
|
+
## Installing
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Normal install:
|
|
9
|
+
pip install .
|
|
10
|
+
# With extra dependencies for development:
|
|
11
|
+
pip install .[dev]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For instructions on setting up the server, refer to Backup-chan server's README.
|
|
15
|
+
|
|
16
|
+
## Testing
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
pytest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Example
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from backupchan import *
|
|
26
|
+
|
|
27
|
+
api = API("http://192.168.1.43", 5000, "your api key")
|
|
28
|
+
|
|
29
|
+
targets = api.list_targets()
|
|
30
|
+
for target in targets:
|
|
31
|
+
print(target)
|
|
32
|
+
|
|
33
|
+
target_id = api.new_target(
|
|
34
|
+
"the waifu collection",
|
|
35
|
+
BackupType.MULTI,
|
|
36
|
+
BackupRecycleCriteria.AGE,
|
|
37
|
+
10,
|
|
38
|
+
BackupRecycleAction.RECYCLE,
|
|
39
|
+
"/var/backups/waifu",
|
|
40
|
+
"wf-$I_$D",
|
|
41
|
+
False,
|
|
42
|
+
None
|
|
43
|
+
)
|
|
44
|
+
target = api.get_target(target_id)
|
|
45
|
+
print(f"Created new target: {target}")
|
|
46
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from .connection import Connection
|
|
3
|
+
from .models import Backup, BackupTarget, BackupRecycleCriteria, BackupRecycleAction, BackupType
|
|
4
|
+
|
|
5
|
+
class BackupchanAPIError(Exception):
|
|
6
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
|
|
10
|
+
def check_success(response: tuple[dict, int]) -> dict:
|
|
11
|
+
data, status = response
|
|
12
|
+
if not data.get("success", False):
|
|
13
|
+
raise BackupchanAPIError(f"Server returned error: {data} (code {status})", status)
|
|
14
|
+
return data
|
|
15
|
+
|
|
16
|
+
class API:
|
|
17
|
+
def __init__(self, host: str, port: int, api_key: str):
|
|
18
|
+
self.connection = Connection(host, port, api_key)
|
|
19
|
+
|
|
20
|
+
def list_targets(self, page: int = 1) -> list[BackupTarget]:
|
|
21
|
+
response = self.connection.get(f"target?page={page}")
|
|
22
|
+
targets = response[0]["targets"]
|
|
23
|
+
return [BackupTarget.from_dict(target) for target in targets]
|
|
24
|
+
|
|
25
|
+
def new_target(self, name: str, backup_type: BackupType, recycle_criteria: BackupRecycleCriteria, recycle_value: int, recycle_action: BackupRecycleAction, location: str, name_template: str, deduplicate: bool, alias: str | None) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Returns ID of new target.
|
|
28
|
+
"""
|
|
29
|
+
data = {
|
|
30
|
+
"name": name,
|
|
31
|
+
"backup_type": backup_type,
|
|
32
|
+
"recycle_criteria": recycle_criteria,
|
|
33
|
+
"recycle_value": recycle_value,
|
|
34
|
+
"recycle_action": recycle_action,
|
|
35
|
+
"location": location,
|
|
36
|
+
"name_template": name_template,
|
|
37
|
+
"deduplicate": deduplicate,
|
|
38
|
+
"alias": alias
|
|
39
|
+
}
|
|
40
|
+
resp_json = check_success(self.connection.post("target", data))
|
|
41
|
+
return resp_json["id"]
|
|
42
|
+
|
|
43
|
+
def upload_backup(self, target_id: str, file: io.IOBase, filename: str, manual: bool) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Returns ID of new backup.
|
|
46
|
+
"""
|
|
47
|
+
data = {
|
|
48
|
+
"manual": int(manual)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
files = {
|
|
52
|
+
"backup_file": (filename, file)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
response = self.connection.post_form(f"target/{target_id}/upload", data=data, files=files)
|
|
56
|
+
resp_json = check_success(response)
|
|
57
|
+
return resp_json["id"]
|
|
58
|
+
|
|
59
|
+
def get_target(self, id: str) -> tuple[BackupTarget, list[Backup]]:
|
|
60
|
+
response = self.connection.get(f"target/{id}")
|
|
61
|
+
resp_json = check_success(response)
|
|
62
|
+
return BackupTarget.from_dict(resp_json["target"]), [Backup.from_dict(backup) for backup in resp_json["backups"]]
|
|
63
|
+
|
|
64
|
+
def edit_target(self, id: str, name: str, recycle_criteria: BackupRecycleCriteria, recycle_value: int, recycle_action: BackupRecycleAction, location: str, name_template: str, deduplicate: bool, alias: str | None):
|
|
65
|
+
data = {
|
|
66
|
+
"name": name,
|
|
67
|
+
"recycle_criteria": recycle_criteria,
|
|
68
|
+
"recycle_value": recycle_value,
|
|
69
|
+
"recycle_action": recycle_action,
|
|
70
|
+
"location": location,
|
|
71
|
+
"name_template": name_template,
|
|
72
|
+
"deduplicate": deduplicate,
|
|
73
|
+
"alias": alias
|
|
74
|
+
}
|
|
75
|
+
response = self.connection.patch(f"target/{id}", data=data)
|
|
76
|
+
check_success(response)
|
|
77
|
+
|
|
78
|
+
def delete_target(self, id: str, delete_files: bool):
|
|
79
|
+
data = {
|
|
80
|
+
"delete_files": delete_files
|
|
81
|
+
}
|
|
82
|
+
response = self.connection.delete(f"target/{id}", data=data)
|
|
83
|
+
check_success(response)
|
|
84
|
+
|
|
85
|
+
def delete_target_backups(self, id: str, delete_files: bool):
|
|
86
|
+
data = {
|
|
87
|
+
"delete_files": delete_files
|
|
88
|
+
}
|
|
89
|
+
response = self.connection.delete(f"target/{id}/all", data=data)
|
|
90
|
+
check_success(response)
|
|
91
|
+
|
|
92
|
+
def delete_backup(self, id: str, delete_files: bool):
|
|
93
|
+
data = {
|
|
94
|
+
"delete_files": delete_files
|
|
95
|
+
}
|
|
96
|
+
response = self.connection.delete(f"backup/{id}", data=data)
|
|
97
|
+
check_success(response)
|
|
98
|
+
|
|
99
|
+
def recycle_backup(self, id: str, is_recycled: bool):
|
|
100
|
+
data = {
|
|
101
|
+
"is_recycled": is_recycled
|
|
102
|
+
}
|
|
103
|
+
response = self.connection.patch(f"backup/{id}", data=data)
|
|
104
|
+
check_success(response)
|
|
105
|
+
|
|
106
|
+
def list_recycled_backups(self) -> list[Backup]:
|
|
107
|
+
response = self.connection.get("recycle_bin")
|
|
108
|
+
resp_json = check_success(response)
|
|
109
|
+
return [Backup.from_dict(backup) for backup in resp_json["backups"]]
|
|
110
|
+
|
|
111
|
+
def clear_recycle_bin(self, delete_files: bool):
|
|
112
|
+
data = {
|
|
113
|
+
"delete_files": delete_files
|
|
114
|
+
}
|
|
115
|
+
response = self.connection.delete("recycle_bin", data=data)
|
|
116
|
+
check_success(response)
|
|
117
|
+
|
|
118
|
+
def get_log(self, tail: int) -> str:
|
|
119
|
+
response = self.connection.get(f"log?tail={tail}")
|
|
120
|
+
resp_json = check_success(response)
|
|
121
|
+
return resp_json["log"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
class Connection:
|
|
5
|
+
def __init__(self, host: str, port: int, api_key: str):
|
|
6
|
+
# TODO check these
|
|
7
|
+
self.api_key = api_key
|
|
8
|
+
|
|
9
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
10
|
+
server_host = host.rstrip("/")
|
|
11
|
+
else:
|
|
12
|
+
server_host = f"http://{host.rstrip('/')}"
|
|
13
|
+
self.base_url = f"{server_host}:{port}"
|
|
14
|
+
|
|
15
|
+
def endpoint_url(self, endpoint: str) -> str:
|
|
16
|
+
return f"{self.base_url}/api/{endpoint.rstrip('/')}"
|
|
17
|
+
|
|
18
|
+
def headers(self) -> dict:
|
|
19
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
20
|
+
|
|
21
|
+
def get(self, endpoint: str, raise_on_error=False) -> tuple[dict, int]:
|
|
22
|
+
response = requests.get(self.endpoint_url(endpoint), headers=self.headers())
|
|
23
|
+
if raise_on_error:
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return response.json(), response.status_code
|
|
26
|
+
|
|
27
|
+
def post(self, endpoint: str, data: dict, raise_on_error=False) -> tuple[dict, int]:
|
|
28
|
+
response = requests.post(self.endpoint_url(endpoint), headers=self.headers(), json=data)
|
|
29
|
+
if raise_on_error:
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
return response.json(), response.status_code
|
|
32
|
+
|
|
33
|
+
def post_form(self, endpoint: str, data: dict, files: dict, raise_on_error=False) -> tuple[dict, int]:
|
|
34
|
+
response = requests.post(self.endpoint_url(endpoint), headers=self.headers(), data=data, files=files)
|
|
35
|
+
if raise_on_error:
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
return response.json(), response.status_code
|
|
38
|
+
|
|
39
|
+
def patch(self, endpoint: str, data: dict, raise_on_error=False) -> tuple[dict, int]:
|
|
40
|
+
response = requests.patch(self.endpoint_url(endpoint), headers=self.headers(), json=data)
|
|
41
|
+
if raise_on_error:
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return response.json(), response.status_code
|
|
44
|
+
|
|
45
|
+
def delete(self, endpoint: str, data: dict, raise_on_error=False) -> tuple[dict, int]:
|
|
46
|
+
response = requests.delete(self.endpoint_url(endpoint), headers=self.headers(), json=data)
|
|
47
|
+
if raise_on_error:
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
return response.json(), response.status_code
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
class BackupRecycleCriteria(str, Enum):
|
|
7
|
+
NONE = "none"
|
|
8
|
+
COUNT = "count"
|
|
9
|
+
AGE = "age"
|
|
10
|
+
|
|
11
|
+
class BackupRecycleAction(str, Enum):
|
|
12
|
+
DELETE = "delete"
|
|
13
|
+
RECYCLE = "recycle"
|
|
14
|
+
|
|
15
|
+
class BackupType(str, Enum):
|
|
16
|
+
SINGLE = "single"
|
|
17
|
+
MULTI = "multi"
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BackupTarget:
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
target_type: BackupType
|
|
24
|
+
recycle_criteria: BackupRecycleCriteria
|
|
25
|
+
recycle_value: Optional[int]
|
|
26
|
+
recycle_action: BackupRecycleAction
|
|
27
|
+
location: str
|
|
28
|
+
name_template: str
|
|
29
|
+
deduplicate: bool
|
|
30
|
+
alias: str | None
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def from_dict(d: dict) -> "BackupTarget":
|
|
34
|
+
return BackupTarget(d["id"], d["name"], d["target_type"], d["recycle_criteria"], d["recycle_value"], d["recycle_action"], d["location"], d["name_template"], d["deduplicate"], d["alias"])
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Backup:
|
|
38
|
+
id: str
|
|
39
|
+
target_id: str
|
|
40
|
+
created_at: datetime
|
|
41
|
+
manual: bool
|
|
42
|
+
is_recycled: bool
|
|
43
|
+
filesize: int
|
|
44
|
+
|
|
45
|
+
def pretty_created_at(self) -> str:
|
|
46
|
+
return self.created_at.strftime("%B %d, %Y %H:%M")
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_dict(d: dict) -> "Backup":
|
|
50
|
+
return Backup(d["id"], d["target_id"], datetime.fromisoformat(d["created_at"]), d["manual"], d["is_recycled"], d["filesize"])
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backupchan-client-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Library for interfacing 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: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: requests-mock; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Backup-chan client library
|
|
25
|
+
|
|
26
|
+
This is the Python library for interfacing with a Backup-chan server.
|
|
27
|
+
|
|
28
|
+
## Installing
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Normal install:
|
|
32
|
+
pip install .
|
|
33
|
+
# With extra dependencies for development:
|
|
34
|
+
pip install .[dev]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For instructions on setting up the server, refer to Backup-chan server's README.
|
|
38
|
+
|
|
39
|
+
## Testing
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Example
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from backupchan import *
|
|
49
|
+
|
|
50
|
+
api = API("http://192.168.1.43", 5000, "your api key")
|
|
51
|
+
|
|
52
|
+
targets = api.list_targets()
|
|
53
|
+
for target in targets:
|
|
54
|
+
print(target)
|
|
55
|
+
|
|
56
|
+
target_id = api.new_target(
|
|
57
|
+
"the waifu collection",
|
|
58
|
+
BackupType.MULTI,
|
|
59
|
+
BackupRecycleCriteria.AGE,
|
|
60
|
+
10,
|
|
61
|
+
BackupRecycleAction.RECYCLE,
|
|
62
|
+
"/var/backups/waifu",
|
|
63
|
+
"wf-$I_$D",
|
|
64
|
+
False,
|
|
65
|
+
None
|
|
66
|
+
)
|
|
67
|
+
target = api.get_target(target_id)
|
|
68
|
+
print(f"Created new target: {target}")
|
|
69
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
LICENSE
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
backupchan/__init__.py
|
|
6
|
+
backupchan/api.py
|
|
7
|
+
backupchan/connection.py
|
|
8
|
+
backupchan/models.py
|
|
9
|
+
backupchan_client_lib.egg-info/PKG-INFO
|
|
10
|
+
backupchan_client_lib.egg-info/SOURCES.txt
|
|
11
|
+
backupchan_client_lib.egg-info/dependency_links.txt
|
|
12
|
+
backupchan_client_lib.egg-info/requires.txt
|
|
13
|
+
backupchan_client_lib.egg-info/top_level.txt
|
|
14
|
+
tests/conftest.py
|
|
15
|
+
tests/test_connection.py
|
|
16
|
+
tests/test_models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backupchan
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "backupchan-client-lib"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description="Library for interfacing with Backup-chan."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name="Moltony", email="koronavirusnyj@gmail.com" } # but I probably won't respond...
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"requests"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = {text = "BSD-3-Clause"}
|
|
14
|
+
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"License :: OSI Approved :: BSD License",
|
|
19
|
+
"Natural Language :: English",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: System :: Archiving :: Backup",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
"Typing :: Typed"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools", "wheel"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest",
|
|
34
|
+
"requests-mock"
|
|
35
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import io
|
|
3
|
+
import pytest
|
|
4
|
+
import requests_mock
|
|
5
|
+
from backupchan import Connection
|
|
6
|
+
|
|
7
|
+
# example responses all taken straight from the api docs
|
|
8
|
+
|
|
9
|
+
NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
|
10
|
+
|
|
11
|
+
def check_request(mock: requests_mock.Mocker, conn: Connection, method: str, payload: None | dict = None):
|
|
12
|
+
last_request = mock.last_request
|
|
13
|
+
assert mock.called
|
|
14
|
+
assert last_request.method == method
|
|
15
|
+
assert last_request.headers["Authorization"] == conn.headers()["Authorization"]
|
|
16
|
+
|
|
17
|
+
if payload is not None:
|
|
18
|
+
if "application/json" in last_request.headers["Content-Type"]:
|
|
19
|
+
assert last_request.json() == payload
|
|
20
|
+
else:
|
|
21
|
+
assert last_request.text is not None
|
|
22
|
+
|
|
23
|
+
def test_get(conn):
|
|
24
|
+
mock_response = {
|
|
25
|
+
"success": True,
|
|
26
|
+
"targets": [
|
|
27
|
+
{
|
|
28
|
+
"id": NULL_UUID,
|
|
29
|
+
"name": "My backup",
|
|
30
|
+
"target_type": "multi",
|
|
31
|
+
"recycle_criteria": "count",
|
|
32
|
+
"recycle_value": 10,
|
|
33
|
+
"recycle_action": "recycle",
|
|
34
|
+
"location": "/var/backups/MyBackup",
|
|
35
|
+
"name_template": "backup-$I-$D"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
with requests_mock.Mocker() as m:
|
|
41
|
+
m.get("http://localhost:5000/api/target", json=mock_response, status_code=200)
|
|
42
|
+
|
|
43
|
+
result, status = conn.get("target")
|
|
44
|
+
|
|
45
|
+
check_request(m, conn, "GET")
|
|
46
|
+
|
|
47
|
+
assert status == 200
|
|
48
|
+
assert result["success"] is True
|
|
49
|
+
assert len(result["targets"]) == 1
|
|
50
|
+
assert result["targets"][0]["name"] == "My backup"
|
|
51
|
+
|
|
52
|
+
def test_post(conn):
|
|
53
|
+
mock_response = {
|
|
54
|
+
"success": True,
|
|
55
|
+
"id": NULL_UUID
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
with requests_mock.Mocker() as m:
|
|
59
|
+
m.post("http://localhost:5000/api/target", json=mock_response, status_code=201)
|
|
60
|
+
|
|
61
|
+
payload = {
|
|
62
|
+
"name": "Backupy",
|
|
63
|
+
"backup_type": "multi",
|
|
64
|
+
"recycle_criteria": "count",
|
|
65
|
+
"recycle_value": 10,
|
|
66
|
+
"recycle_action": "recycle",
|
|
67
|
+
"location": "/bakupy",
|
|
68
|
+
"name_template": "bkp-$I"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
result, status = conn.post("target", payload)
|
|
72
|
+
|
|
73
|
+
check_request(m, conn, "POST", payload)
|
|
74
|
+
|
|
75
|
+
assert status == 201
|
|
76
|
+
assert result["success"] is True
|
|
77
|
+
assert result["id"] == NULL_UUID
|
|
78
|
+
|
|
79
|
+
def test_post_form(conn):
|
|
80
|
+
mock_response = {
|
|
81
|
+
"success": True,
|
|
82
|
+
"id": NULL_UUID
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test_uuid = str(uuid.uuid4())
|
|
86
|
+
|
|
87
|
+
with requests_mock.Mocker() as m:
|
|
88
|
+
m.post(f"http://localhost:5000/api/target/{test_uuid}/upload", json=mock_response, status_code=200)
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
"manual": False
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
files = {
|
|
95
|
+
"backup_file": io.BytesIO(b"i am file")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
result, status = conn.post_form(f"target/{test_uuid}/upload", data=payload, files=files)
|
|
99
|
+
|
|
100
|
+
last_request = m.last_request
|
|
101
|
+
check_request(m, conn, "POST", payload)
|
|
102
|
+
assert "multipart/form-data" in last_request.headers["Content-Type"]
|
|
103
|
+
|
|
104
|
+
assert status == 200
|
|
105
|
+
assert result["success"] is True
|
|
106
|
+
assert result["id"] == NULL_UUID
|
|
107
|
+
|
|
108
|
+
def test_delete(conn):
|
|
109
|
+
mock_response = {
|
|
110
|
+
"success": True
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
with requests_mock.Mocker() as m:
|
|
114
|
+
m.delete(f"http://localhost:5000/api/target/{NULL_UUID}", json=mock_response, status_code=200)
|
|
115
|
+
|
|
116
|
+
payload = {
|
|
117
|
+
"delete_files": True
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
result, status = conn.delete(f"target/{NULL_UUID}", data=payload)
|
|
121
|
+
|
|
122
|
+
check_request(m, conn, "DELETE", payload)
|
|
123
|
+
|
|
124
|
+
assert status == 200
|
|
125
|
+
assert result["success"] is True
|
|
126
|
+
|
|
127
|
+
def test_patch(conn):
|
|
128
|
+
mock_response = {
|
|
129
|
+
"success": True
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
with requests_mock.Mocker() as m:
|
|
133
|
+
m.patch(f"http://localhost:5000/api/backup/{NULL_UUID}", json=mock_response, status_code=200)
|
|
134
|
+
|
|
135
|
+
payload = {
|
|
136
|
+
"is_recycled": True
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result, status = conn.patch(f"backup/{NULL_UUID}", data=payload)
|
|
140
|
+
|
|
141
|
+
check_request(m, conn, "PATCH", payload)
|
|
142
|
+
|
|
143
|
+
assert status == 200
|
|
144
|
+
assert result["success"] is True
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from backupchan.models import Backup, BackupType, BackupRecycleAction, BackupRecycleCriteria, BackupTarget
|
|
3
|
+
|
|
4
|
+
def test_target_from_dict():
|
|
5
|
+
json_target = {
|
|
6
|
+
"id": "deadbeef-dead-beef-dead-beefdeadbeef",
|
|
7
|
+
"name": "touhoku kiritest",
|
|
8
|
+
"target_type": "multi",
|
|
9
|
+
"recycle_criteria": "count",
|
|
10
|
+
"recycle_value": 13,
|
|
11
|
+
"recycle_action": "recycle",
|
|
12
|
+
"location": "/var/backups/touhoku",
|
|
13
|
+
"name_template": "$I_kiritanpo",
|
|
14
|
+
"deduplicate": False,
|
|
15
|
+
"alias": None
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
target = BackupTarget.from_dict(json_target)
|
|
19
|
+
assert target.id == json_target["id"]
|
|
20
|
+
assert target.name == json_target["name"]
|
|
21
|
+
assert target.target_type == BackupType.MULTI
|
|
22
|
+
assert target.recycle_criteria == BackupRecycleCriteria.COUNT
|
|
23
|
+
assert target.recycle_value == json_target["recycle_value"]
|
|
24
|
+
assert target.recycle_action == BackupRecycleAction.RECYCLE
|
|
25
|
+
assert target.location == json_target["location"]
|
|
26
|
+
assert target.name_template == json_target["name_template"]
|
|
27
|
+
assert target.deduplicate == json_target["deduplicate"]
|
|
28
|
+
assert target.alias == json_target["alias"]
|
|
29
|
+
|
|
30
|
+
def test_backup_from_dict():
|
|
31
|
+
created_at = datetime.datetime.now()
|
|
32
|
+
json_backup = {
|
|
33
|
+
"id": "d0d0caca-d0d0-caca-d0d0-cacad0d0caca",
|
|
34
|
+
"target_id": "deadbeef-dead-beef-dead-beefdeadbeef",
|
|
35
|
+
"created_at": created_at.isoformat(),
|
|
36
|
+
"manual": False,
|
|
37
|
+
"is_recycled": True,
|
|
38
|
+
"filesize": 123456
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
backup = Backup.from_dict(json_backup)
|
|
42
|
+
assert backup.id == json_backup["id"]
|
|
43
|
+
assert backup.target_id == json_backup["target_id"]
|
|
44
|
+
assert backup.created_at == created_at
|
|
45
|
+
assert backup.manual == json_backup["manual"]
|
|
46
|
+
assert backup.is_recycled == json_backup["is_recycled"]
|
|
47
|
+
assert backup.filesize == json_backup["filesize"]
|