ptctools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ptctools/__init__.py +3 -0
- ptctools/_portainer.py +279 -0
- ptctools/_s3.py +150 -0
- ptctools/cli.py +28 -0
- ptctools/config.py +293 -0
- ptctools/db.py +544 -0
- ptctools/stack.py +367 -0
- ptctools/utils.py +416 -0
- ptctools/volume.py +359 -0
- ptctools-0.1.0.dist-info/METADATA +99 -0
- ptctools-0.1.0.dist-info/RECORD +14 -0
- ptctools-0.1.0.dist-info/WHEEL +4 -0
- ptctools-0.1.0.dist-info/entry_points.txt +2 -0
- ptctools-0.1.0.dist-info/licenses/LICENSE +201 -0
ptctools/__init__.py
ADDED
ptctools/_portainer.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common utility functions for Portainer API scripts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def api_request(
|
|
13
|
+
url: str,
|
|
14
|
+
api_key: str,
|
|
15
|
+
method: str = "GET",
|
|
16
|
+
data: dict | None = None,
|
|
17
|
+
) -> tuple[dict | list | None, int]:
|
|
18
|
+
"""Make an API request to Portainer."""
|
|
19
|
+
headers = {
|
|
20
|
+
"X-API-Key": api_key,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
body = json.dumps(data).encode("utf-8") if data else None
|
|
25
|
+
req = Request(url, data=body, headers=headers, method=method)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
with urlopen(req) as response:
|
|
29
|
+
response_body = response.read().decode("utf-8")
|
|
30
|
+
return json.loads(response_body) if response_body else None, response.status
|
|
31
|
+
except HTTPError as e:
|
|
32
|
+
response_body = e.read().decode("utf-8")
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(response_body), e.code
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return {"error": response_body}, e.code
|
|
37
|
+
except URLError as e:
|
|
38
|
+
return {"error": str(e.reason)}, 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_container(
|
|
42
|
+
portainer_url: str,
|
|
43
|
+
api_key: str,
|
|
44
|
+
endpoint_id: int,
|
|
45
|
+
config: dict,
|
|
46
|
+
) -> str | None:
|
|
47
|
+
"""Create a container and return its ID."""
|
|
48
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/create"
|
|
49
|
+
response, status_code = api_request(url, api_key, method="POST", data=config)
|
|
50
|
+
|
|
51
|
+
if 200 <= status_code < 300 and response:
|
|
52
|
+
return response.get("Id")
|
|
53
|
+
else:
|
|
54
|
+
print(f"Error creating container (HTTP {status_code}):")
|
|
55
|
+
print(json.dumps(response, indent=2))
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pull_image(
|
|
60
|
+
portainer_url: str,
|
|
61
|
+
api_key: str,
|
|
62
|
+
endpoint_id: int,
|
|
63
|
+
image: str,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Pull a Docker image."""
|
|
66
|
+
from urllib.request import Request, urlopen
|
|
67
|
+
from urllib.parse import quote
|
|
68
|
+
|
|
69
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/images/create?fromImage={quote(image)}"
|
|
70
|
+
headers = {"X-API-Key": api_key}
|
|
71
|
+
req = Request(url, data=b"", headers=headers, method="POST")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with urlopen(req) as response:
|
|
75
|
+
# Read streaming response (Docker sends progress updates)
|
|
76
|
+
response.read()
|
|
77
|
+
return response.status == 200
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def start_container(
|
|
83
|
+
portainer_url: str,
|
|
84
|
+
api_key: str,
|
|
85
|
+
endpoint_id: int,
|
|
86
|
+
container_id: str,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Start a container."""
|
|
89
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/start"
|
|
90
|
+
_, status_code = api_request(url, api_key, method="POST")
|
|
91
|
+
return 200 <= status_code < 300
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wait_container(
|
|
95
|
+
portainer_url: str,
|
|
96
|
+
api_key: str,
|
|
97
|
+
endpoint_id: int,
|
|
98
|
+
container_id: str,
|
|
99
|
+
) -> int:
|
|
100
|
+
"""Wait for a container to finish and return exit code."""
|
|
101
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/wait"
|
|
102
|
+
response, status_code = api_request(url, api_key, method="POST")
|
|
103
|
+
|
|
104
|
+
if 200 <= status_code < 300 and response:
|
|
105
|
+
return response.get("StatusCode", -1)
|
|
106
|
+
return -1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_container_logs(
|
|
110
|
+
portainer_url: str,
|
|
111
|
+
api_key: str,
|
|
112
|
+
endpoint_id: int,
|
|
113
|
+
container_id: str,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Get container logs."""
|
|
116
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/logs?stdout=true&stderr=true"
|
|
117
|
+
|
|
118
|
+
headers = {"X-API-Key": api_key}
|
|
119
|
+
req = Request(url, headers=headers, method="GET")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with urlopen(req) as response:
|
|
123
|
+
# Docker logs have stream headers, strip them
|
|
124
|
+
raw_logs = response.read()
|
|
125
|
+
# Simple cleanup - remove Docker stream headers (8 bytes each)
|
|
126
|
+
logs = ""
|
|
127
|
+
i = 0
|
|
128
|
+
while i < len(raw_logs):
|
|
129
|
+
if i + 8 <= len(raw_logs):
|
|
130
|
+
size = int.from_bytes(raw_logs[i + 4 : i + 8], "big")
|
|
131
|
+
if i + 8 + size <= len(raw_logs):
|
|
132
|
+
logs += raw_logs[i + 8 : i + 8 + size].decode(
|
|
133
|
+
"utf-8", errors="replace"
|
|
134
|
+
)
|
|
135
|
+
i += 8 + size
|
|
136
|
+
continue
|
|
137
|
+
break
|
|
138
|
+
return logs
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return f"Error getting logs: {e}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def remove_container(
|
|
144
|
+
portainer_url: str,
|
|
145
|
+
api_key: str,
|
|
146
|
+
endpoint_id: int,
|
|
147
|
+
container_id: str,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Remove a container."""
|
|
150
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}?force=true"
|
|
151
|
+
_, status_code = api_request(url, api_key, method="DELETE")
|
|
152
|
+
return 200 <= status_code < 300
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def find_container_by_name(
|
|
156
|
+
portainer_url: str,
|
|
157
|
+
api_key: str,
|
|
158
|
+
endpoint_id: int,
|
|
159
|
+
name_filter: str,
|
|
160
|
+
) -> str | None:
|
|
161
|
+
"""Find a running container by name filter and return its ID."""
|
|
162
|
+
url = f'{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/json?filters={{"name":["{name_filter}"]}}'
|
|
163
|
+
response, status_code = api_request(url, api_key)
|
|
164
|
+
|
|
165
|
+
if 200 <= status_code < 300 and isinstance(response, list) and len(response) > 0:
|
|
166
|
+
return response[0].get("Id")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_network_id(
|
|
171
|
+
portainer_url: str,
|
|
172
|
+
api_key: str,
|
|
173
|
+
endpoint_id: int,
|
|
174
|
+
network_name: str,
|
|
175
|
+
) -> str | None:
|
|
176
|
+
"""Find a network by name and return its ID."""
|
|
177
|
+
url = f'{portainer_url}/api/endpoints/{endpoint_id}/docker/networks?filters={{"name":["{network_name}"]}}'
|
|
178
|
+
response, status_code = api_request(url, api_key)
|
|
179
|
+
|
|
180
|
+
if 200 <= status_code < 300 and isinstance(response, list) and len(response) > 0:
|
|
181
|
+
return response[0].get("Id")
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_exec(
|
|
186
|
+
portainer_url: str,
|
|
187
|
+
api_key: str,
|
|
188
|
+
endpoint_id: int,
|
|
189
|
+
container_id: str,
|
|
190
|
+
cmd: list[str],
|
|
191
|
+
) -> str | None:
|
|
192
|
+
"""Create an exec instance in a container and return the exec ID."""
|
|
193
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/exec"
|
|
194
|
+
config = {
|
|
195
|
+
"AttachStdout": True,
|
|
196
|
+
"AttachStderr": True,
|
|
197
|
+
"Cmd": cmd,
|
|
198
|
+
}
|
|
199
|
+
response, status_code = api_request(url, api_key, method="POST", data=config)
|
|
200
|
+
|
|
201
|
+
if 200 <= status_code < 300 and response:
|
|
202
|
+
return response.get("Id")
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def start_exec(
|
|
207
|
+
portainer_url: str,
|
|
208
|
+
api_key: str,
|
|
209
|
+
endpoint_id: int,
|
|
210
|
+
exec_id: str,
|
|
211
|
+
) -> tuple[int, str]:
|
|
212
|
+
"""Start an exec instance and return (exit_code, output)."""
|
|
213
|
+
from urllib.request import Request, urlopen
|
|
214
|
+
|
|
215
|
+
url = f"{portainer_url}/api/endpoints/{endpoint_id}/docker/exec/{exec_id}/start"
|
|
216
|
+
headers = {
|
|
217
|
+
"X-API-Key": api_key,
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
}
|
|
220
|
+
body = json.dumps({"Detach": False, "Tty": False}).encode("utf-8")
|
|
221
|
+
req = Request(url, data=body, headers=headers, method="POST")
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
with urlopen(req) as response:
|
|
225
|
+
# Read raw output (has Docker stream headers like logs)
|
|
226
|
+
raw_output = response.read()
|
|
227
|
+
# Strip Docker stream headers
|
|
228
|
+
output = ""
|
|
229
|
+
i = 0
|
|
230
|
+
while i < len(raw_output):
|
|
231
|
+
if i + 8 <= len(raw_output):
|
|
232
|
+
size = int.from_bytes(raw_output[i + 4 : i + 8], "big")
|
|
233
|
+
if i + 8 + size <= len(raw_output):
|
|
234
|
+
output += raw_output[i + 8 : i + 8 + size].decode(
|
|
235
|
+
"utf-8", errors="replace"
|
|
236
|
+
)
|
|
237
|
+
i += 8 + size
|
|
238
|
+
continue
|
|
239
|
+
break
|
|
240
|
+
except Exception as e:
|
|
241
|
+
return -1, f"Error: {e}"
|
|
242
|
+
|
|
243
|
+
# Get exec exit code
|
|
244
|
+
inspect_url = (
|
|
245
|
+
f"{portainer_url}/api/endpoints/{endpoint_id}/docker/exec/{exec_id}/json"
|
|
246
|
+
)
|
|
247
|
+
inspect_resp, _ = api_request(inspect_url, api_key)
|
|
248
|
+
exit_code = inspect_resp.get("ExitCode", -1) if inspect_resp else -1
|
|
249
|
+
|
|
250
|
+
return exit_code, output
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run_ephemeral_container(
|
|
254
|
+
portainer_url: str,
|
|
255
|
+
api_key: str,
|
|
256
|
+
endpoint_id: int,
|
|
257
|
+
config: dict,
|
|
258
|
+
pull: bool = True,
|
|
259
|
+
) -> tuple[int, str]:
|
|
260
|
+
"""Run an ephemeral container and return (exit_code, logs)."""
|
|
261
|
+
image = config.get("Image")
|
|
262
|
+
if pull and image:
|
|
263
|
+
if not pull_image(portainer_url, api_key, endpoint_id, image):
|
|
264
|
+
print(f"Warning: Failed to pull image {image}, trying to run anyway...")
|
|
265
|
+
|
|
266
|
+
container_id = create_container(portainer_url, api_key, endpoint_id, config)
|
|
267
|
+
if not container_id:
|
|
268
|
+
return -1, "Failed to create container"
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
if not start_container(portainer_url, api_key, endpoint_id, container_id):
|
|
272
|
+
return -1, "Failed to start container"
|
|
273
|
+
|
|
274
|
+
exit_code = wait_container(portainer_url, api_key, endpoint_id, container_id)
|
|
275
|
+
logs = get_container_logs(portainer_url, api_key, endpoint_id, container_id)
|
|
276
|
+
|
|
277
|
+
return exit_code, logs
|
|
278
|
+
finally:
|
|
279
|
+
remove_container(portainer_url, api_key, endpoint_id, container_id)
|
ptctools/_s3.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""S3 utility functions for URI parsing and operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_s3_uri(uri: str) -> tuple[str | None, str, str]:
|
|
13
|
+
"""Parse S3 URI into (endpoint, bucket, path).
|
|
14
|
+
|
|
15
|
+
Supports formats:
|
|
16
|
+
- s3://bucket/path
|
|
17
|
+
- s3://bucket/path?s3-server-name=endpoint
|
|
18
|
+
- s3://endpoint.com/bucket/path (endpoint detected by dots in first segment)
|
|
19
|
+
- https://endpoint.com/bucket/path
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Tuple of (endpoint or None, bucket, path)
|
|
23
|
+
- endpoint: The S3 endpoint URL or None if not specified
|
|
24
|
+
- bucket: The bucket name
|
|
25
|
+
- path: The object path within the bucket (may be empty)
|
|
26
|
+
"""
|
|
27
|
+
if not uri:
|
|
28
|
+
raise click.ClickException("Empty S3 URI")
|
|
29
|
+
|
|
30
|
+
# Handle https:// or http:// scheme
|
|
31
|
+
if uri.startswith("https://") or uri.startswith("http://"):
|
|
32
|
+
parsed = urlparse(uri)
|
|
33
|
+
endpoint = f"{parsed.scheme}://{parsed.netloc}"
|
|
34
|
+
path_parts = parsed.path.strip("/").split("/", 1)
|
|
35
|
+
bucket = path_parts[0] if path_parts else ""
|
|
36
|
+
path = path_parts[1] if len(path_parts) > 1 else ""
|
|
37
|
+
|
|
38
|
+
if not bucket:
|
|
39
|
+
raise click.ClickException(f"Could not parse bucket from URI: {uri}")
|
|
40
|
+
|
|
41
|
+
return endpoint, bucket, path
|
|
42
|
+
|
|
43
|
+
# Handle s3:// scheme
|
|
44
|
+
if not uri.startswith("s3://"):
|
|
45
|
+
raise click.ClickException(
|
|
46
|
+
f"Invalid S3 URI format: {uri}. Expected s3://bucket/path or https://endpoint/bucket/path"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
rest = uri[5:] # Remove "s3://"
|
|
50
|
+
|
|
51
|
+
# Check for query parameter format: s3://bucket/path?s3-server-name=endpoint
|
|
52
|
+
if "?s3-server-name=" in rest:
|
|
53
|
+
match = re.match(r"([^/]+)/(.+?)\?s3-server-name=(.+)$", rest)
|
|
54
|
+
if match:
|
|
55
|
+
bucket = match.group(1)
|
|
56
|
+
path = match.group(2)
|
|
57
|
+
endpoint = match.group(3)
|
|
58
|
+
# Add https:// if not present
|
|
59
|
+
if not endpoint.startswith("http://") and not endpoint.startswith("https://"):
|
|
60
|
+
endpoint = f"https://{endpoint}"
|
|
61
|
+
return endpoint, bucket, path
|
|
62
|
+
|
|
63
|
+
# Split by first slash
|
|
64
|
+
parts = rest.split("/", 1)
|
|
65
|
+
first_part = parts[0]
|
|
66
|
+
remaining = parts[1] if len(parts) > 1 else ""
|
|
67
|
+
|
|
68
|
+
# Check if first part looks like an endpoint (has dots) or just bucket name
|
|
69
|
+
if "." in first_part:
|
|
70
|
+
# It's an endpoint like s3://minio.example.com/bucket/path
|
|
71
|
+
endpoint = f"https://{first_part}"
|
|
72
|
+
# remaining is now bucket/path
|
|
73
|
+
sub_parts = remaining.split("/", 1)
|
|
74
|
+
bucket = sub_parts[0] if sub_parts else ""
|
|
75
|
+
path = sub_parts[1] if len(sub_parts) > 1 else ""
|
|
76
|
+
else:
|
|
77
|
+
# It's just a bucket name like s3://bucket/path
|
|
78
|
+
endpoint = None
|
|
79
|
+
bucket = first_part
|
|
80
|
+
path = remaining
|
|
81
|
+
|
|
82
|
+
if not bucket:
|
|
83
|
+
raise click.ClickException(f"Could not parse bucket from S3 URI: {uri}")
|
|
84
|
+
|
|
85
|
+
return endpoint, bucket, path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_s3_uri(path: str) -> bool:
|
|
89
|
+
"""Check if path is an S3 URI."""
|
|
90
|
+
return path.startswith("s3://") or path.startswith("https://") or path.startswith("http://")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_s3_endpoint(uri_endpoint: str | None, cli_endpoint: str | None = None) -> str:
|
|
94
|
+
"""Get S3 endpoint from various sources.
|
|
95
|
+
|
|
96
|
+
Priority: CLI argument > URI embedded > environment variable
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
uri_endpoint: Endpoint parsed from URI (may be None)
|
|
100
|
+
cli_endpoint: Endpoint from CLI argument (may be None)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The resolved endpoint URL
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ClickException if no endpoint can be resolved
|
|
107
|
+
"""
|
|
108
|
+
endpoint = cli_endpoint or uri_endpoint or os.environ.get("S3_ENDPOINT")
|
|
109
|
+
if not endpoint:
|
|
110
|
+
raise click.ClickException(
|
|
111
|
+
"S3 endpoint required. Use --s3-endpoint, embed in URI (s3://endpoint/bucket), "
|
|
112
|
+
"or set S3_ENDPOINT environment variable"
|
|
113
|
+
)
|
|
114
|
+
return endpoint
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_s3_credentials() -> tuple[str, str]:
|
|
118
|
+
"""Get S3 credentials from environment variables.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of (access_key, secret_key)
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ClickException if credentials are missing
|
|
125
|
+
"""
|
|
126
|
+
s3_access_key = os.environ.get("S3_ACCESS_KEY")
|
|
127
|
+
s3_secret_key = os.environ.get("S3_SECRET_KEY")
|
|
128
|
+
if not s3_access_key or not s3_secret_key:
|
|
129
|
+
raise click.ClickException(
|
|
130
|
+
"Missing S3_ACCESS_KEY or S3_SECRET_KEY environment variables"
|
|
131
|
+
)
|
|
132
|
+
return s3_access_key, s3_secret_key
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_duplicati_s3_url(bucket: str, path: str, endpoint: str) -> str:
|
|
136
|
+
"""Build Duplicati-compatible S3 URL with query parameter.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
bucket: S3 bucket name
|
|
140
|
+
path: Object path within bucket
|
|
141
|
+
endpoint: S3 endpoint URL
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Duplicati-compatible S3 URL like: s3://bucket/path?s3-server-name=host
|
|
145
|
+
"""
|
|
146
|
+
s3_host = endpoint.replace("https://", "").replace("http://", "").rstrip("/")
|
|
147
|
+
if path:
|
|
148
|
+
return f"s3://{bucket}/{path}?s3-server-name={s3_host}"
|
|
149
|
+
else:
|
|
150
|
+
return f"s3://{bucket}?s3-server-name={s3_host}"
|
ptctools/cli.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Main CLI entry point for ptctools."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ptctools import __version__
|
|
6
|
+
from ptctools import stack
|
|
7
|
+
from ptctools import volume
|
|
8
|
+
from ptctools import db
|
|
9
|
+
from ptctools import utils
|
|
10
|
+
from ptctools import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(version=__version__)
|
|
15
|
+
def main():
|
|
16
|
+
"""Portainer Client Tools - manage stacks, volumes, and databases."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
main.add_command(stack.cli, name="stack")
|
|
21
|
+
main.add_command(volume.cli, name="volume")
|
|
22
|
+
main.add_command(db.cli, name="db")
|
|
23
|
+
main.add_command(utils.cli, name="utils")
|
|
24
|
+
main.add_command(config.cli, name="config")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|