simplismart-sdk 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.
- simplismart/__init__.py +40 -0
- simplismart/api/__init__.py +9 -0
- simplismart/api/deployments.py +218 -0
- simplismart/api/model_repos.py +75 -0
- simplismart/api/secrets.py +41 -0
- simplismart/cli/__init__.py +1 -0
- simplismart/cli/common.py +93 -0
- simplismart/cli/deployments.py +231 -0
- simplismart/cli/main.py +46 -0
- simplismart/cli/model_repos.py +290 -0
- simplismart/cli/secrets.py +88 -0
- simplismart/client.py +228 -0
- simplismart/config.py +27 -0
- simplismart/exceptions.py +13 -0
- simplismart/http.py +89 -0
- simplismart/models/__init__.py +19 -0
- simplismart/models/_shared.py +24 -0
- simplismart/models/deployment.py +184 -0
- simplismart/models/model_repo.py +365 -0
- simplismart/models/secret.py +26 -0
- simplismart_sdk-0.1.0.dist-info/METADATA +450 -0
- simplismart_sdk-0.1.0.dist-info/RECORD +25 -0
- simplismart_sdk-0.1.0.dist-info/WHEEL +5 -0
- simplismart_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- simplismart_sdk-0.1.0.dist-info/top_level.txt +1 -0
simplismart/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Simplismart Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import SimplismartError
|
|
8
|
+
from .models import (
|
|
9
|
+
DeploymentCreate,
|
|
10
|
+
ModelRepoCompileAvatar,
|
|
11
|
+
ModelRepoCompileCreate,
|
|
12
|
+
ModelRepoCreate,
|
|
13
|
+
ModelRepoListParams,
|
|
14
|
+
ModelRepoProfilesRequest,
|
|
15
|
+
SecretCreate,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Simplismart",
|
|
20
|
+
"SimplismartClient",
|
|
21
|
+
"SimplismartError",
|
|
22
|
+
"DeploymentCreate",
|
|
23
|
+
"ModelRepoCreate",
|
|
24
|
+
"ModelRepoCompileCreate",
|
|
25
|
+
"ModelRepoCompileAvatar",
|
|
26
|
+
"ModelRepoListParams",
|
|
27
|
+
"ModelRepoProfilesRequest",
|
|
28
|
+
"SecretCreate",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __getattr__(name: str) -> Any:
|
|
35
|
+
"""Lazy-load client symbols so model imports stay lightweight."""
|
|
36
|
+
if name in {"Simplismart", "SimplismartClient"}:
|
|
37
|
+
from .client import Simplismart, SimplismartClient
|
|
38
|
+
|
|
39
|
+
return {"Simplismart": Simplismart, "SimplismartClient": SimplismartClient}[name]
|
|
40
|
+
raise AttributeError(f"module 'simplismart' has no attribute '{name}'")
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from ..http import HTTPClient
|
|
6
|
+
from ..models.deployment import DeploymentCreate
|
|
7
|
+
|
|
8
|
+
_MAX_PAGE_SIZE = 20
|
|
9
|
+
_MAX_PAGE_OFFSET = 10_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_status_filter(status: str | None) -> str | None:
|
|
13
|
+
if status is None:
|
|
14
|
+
return None
|
|
15
|
+
normalized = status.strip().upper()
|
|
16
|
+
return normalized or None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _status_matches(value: Any, *, status_filter: str) -> bool:
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
return False
|
|
22
|
+
normalized = value.upper()
|
|
23
|
+
return normalized == status_filter or normalized.startswith(f"{status_filter}_")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeploymentAPI:
|
|
27
|
+
"""
|
|
28
|
+
Deployment operations exposed to PG-authenticated SDK clients.
|
|
29
|
+
|
|
30
|
+
- Private deployment create: `/deployments/private/deploy-model/`
|
|
31
|
+
- Model deployment create (BYOC-oriented): `/deployments/model-deployments/`
|
|
32
|
+
- Model deployment read/delete: `/deployments/model-deployments/{id}/`
|
|
33
|
+
- Generic deployment edit: `/deployments/<id>/edit`
|
|
34
|
+
- Shared lifecycle ops: start/stop/restart/health/autoscaling/delete
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
38
|
+
self._http = http
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
model_repo_id: Optional[str] = None,
|
|
44
|
+
status: str | None = None,
|
|
45
|
+
offset: int = 0,
|
|
46
|
+
count: int = 20,
|
|
47
|
+
trace_id: str | None = None,
|
|
48
|
+
) -> Any:
|
|
49
|
+
"""
|
|
50
|
+
List deployments for the org associated with the token.
|
|
51
|
+
If model_repo_id is provided, filter to that repo.
|
|
52
|
+
|
|
53
|
+
Note: the current backend list endpoint does not expose offset/count query params.
|
|
54
|
+
SDK applies client-side pagination for consistency with model-repo listing.
|
|
55
|
+
"""
|
|
56
|
+
normalized_status = _normalize_status_filter(status)
|
|
57
|
+
safe_offset = max(0, min(offset, _MAX_PAGE_OFFSET))
|
|
58
|
+
safe_count = max(0, min(count, _MAX_PAGE_SIZE))
|
|
59
|
+
params: dict[str, Any] = {}
|
|
60
|
+
if model_repo_id:
|
|
61
|
+
params["model_repo_id"] = model_repo_id
|
|
62
|
+
if normalized_status:
|
|
63
|
+
params["status"] = normalized_status
|
|
64
|
+
response = self._http.request(
|
|
65
|
+
"GET",
|
|
66
|
+
"/deployments/list/model/",
|
|
67
|
+
params=params or None,
|
|
68
|
+
trace_id=trace_id,
|
|
69
|
+
)
|
|
70
|
+
if isinstance(response, list):
|
|
71
|
+
if normalized_status:
|
|
72
|
+
response = [
|
|
73
|
+
item
|
|
74
|
+
for item in response
|
|
75
|
+
if _status_matches(item.get("status"), status_filter=normalized_status)
|
|
76
|
+
]
|
|
77
|
+
end = safe_offset + safe_count
|
|
78
|
+
return response[safe_offset:end]
|
|
79
|
+
return response
|
|
80
|
+
|
|
81
|
+
def create_private_deployment(
|
|
82
|
+
self,
|
|
83
|
+
payload: DeploymentCreate,
|
|
84
|
+
*,
|
|
85
|
+
trace_id: str | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
return self._http.request(
|
|
88
|
+
"POST",
|
|
89
|
+
"/deployments/private/deploy-model/",
|
|
90
|
+
json=payload.to_payload(),
|
|
91
|
+
trace_id=trace_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def list_model_deployments(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
org_id: Optional[str] = None,
|
|
98
|
+
trace_id: str | None = None,
|
|
99
|
+
) -> Any:
|
|
100
|
+
params = {"org": org_id} if org_id else None
|
|
101
|
+
return self._http.request(
|
|
102
|
+
"GET",
|
|
103
|
+
"/deployments/model-deployments/",
|
|
104
|
+
params=params,
|
|
105
|
+
trace_id=trace_id,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def create_byoc_deployment(self, payload: dict[str, Any], *, trace_id: str | None = None) -> Any:
|
|
109
|
+
return self._http.request(
|
|
110
|
+
"POST",
|
|
111
|
+
"/deployments/model-deployments/",
|
|
112
|
+
json=payload,
|
|
113
|
+
trace_id=trace_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_model_deployment(self, deployment_id: str, *, trace_id: str | None = None) -> Any:
|
|
117
|
+
return self._http.request(
|
|
118
|
+
"GET",
|
|
119
|
+
f"/deployments/model-deployments/{deployment_id}/",
|
|
120
|
+
trace_id=trace_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def delete_model_deployment(self, deployment_id: str, *, trace_id: str | None = None) -> bool:
|
|
124
|
+
self._http.request(
|
|
125
|
+
"DELETE",
|
|
126
|
+
f"/deployments/model-deployments/{deployment_id}/",
|
|
127
|
+
trace_id=trace_id,
|
|
128
|
+
)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def update_deployment(
|
|
132
|
+
self,
|
|
133
|
+
deployment_id: str,
|
|
134
|
+
payload: dict[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
trace_id: str | None = None,
|
|
137
|
+
) -> Any:
|
|
138
|
+
return self._http.request(
|
|
139
|
+
"POST",
|
|
140
|
+
f"/deployments/{deployment_id}/edit",
|
|
141
|
+
json=payload,
|
|
142
|
+
trace_id=trace_id,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def stop(self, deployment_id: str, *, org_id: Optional[str] = None, trace_id: str | None = None) -> Any:
|
|
146
|
+
body: dict[str, Any] = {"action": "stop"}
|
|
147
|
+
if org_id:
|
|
148
|
+
body["org"] = org_id
|
|
149
|
+
return self._http.request(
|
|
150
|
+
"POST",
|
|
151
|
+
f"/deployments/{deployment_id}/startstop/",
|
|
152
|
+
json=body,
|
|
153
|
+
trace_id=trace_id,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def start(self, deployment_id: str, *, org_id: Optional[str] = None, trace_id: str | None = None) -> Any:
|
|
157
|
+
body: dict[str, Any] = {"action": "start"}
|
|
158
|
+
if org_id:
|
|
159
|
+
body["org"] = org_id
|
|
160
|
+
return self._http.request(
|
|
161
|
+
"POST",
|
|
162
|
+
f"/deployments/{deployment_id}/startstop/",
|
|
163
|
+
json=body,
|
|
164
|
+
trace_id=trace_id,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def restart(
|
|
168
|
+
self,
|
|
169
|
+
deployment_id: str,
|
|
170
|
+
*,
|
|
171
|
+
org_id: str,
|
|
172
|
+
namespace: str,
|
|
173
|
+
trace_id: str | None = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
return self._http.request(
|
|
176
|
+
"POST",
|
|
177
|
+
f"/deployments/{deployment_id}/restart/",
|
|
178
|
+
json={"action": "restart", "org": org_id, "namespace": namespace},
|
|
179
|
+
trace_id=trace_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def fetch_health(self, deployment_id: str, *, trace_id: str | None = None) -> Any:
|
|
183
|
+
return self._http.request(
|
|
184
|
+
"GET",
|
|
185
|
+
f"/deployments/model-deployment/fetch-status/{deployment_id}/",
|
|
186
|
+
trace_id=trace_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def update_autoscaling(
|
|
190
|
+
self,
|
|
191
|
+
deployment_id: str,
|
|
192
|
+
*,
|
|
193
|
+
min_replicas: int,
|
|
194
|
+
max_replicas: int,
|
|
195
|
+
trace_id: str | None = None,
|
|
196
|
+
) -> Any:
|
|
197
|
+
return self._http.request(
|
|
198
|
+
"POST",
|
|
199
|
+
f"/deployments/{deployment_id}/update-replicas/",
|
|
200
|
+
json={"min_replicas": min_replicas, "max_replicas": max_replicas},
|
|
201
|
+
trace_id=trace_id,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def delete(
|
|
205
|
+
self,
|
|
206
|
+
deployment_id: str,
|
|
207
|
+
*,
|
|
208
|
+
org_id: Optional[str] = None,
|
|
209
|
+
trace_id: str | None = None,
|
|
210
|
+
) -> bool:
|
|
211
|
+
params = {"org_id": org_id} if org_id else None
|
|
212
|
+
self._http.request(
|
|
213
|
+
"DELETE",
|
|
214
|
+
f"/deployments/{deployment_id}/delete/",
|
|
215
|
+
params=params,
|
|
216
|
+
trace_id=trace_id,
|
|
217
|
+
)
|
|
218
|
+
return True
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from ..http import HTTPClient
|
|
6
|
+
from ..models.model_repo import (
|
|
7
|
+
ModelRepoCompileCreate,
|
|
8
|
+
ModelRepoCreate,
|
|
9
|
+
ModelRepoListParams,
|
|
10
|
+
ModelRepoProfilesRequest,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelRepoAPI:
|
|
15
|
+
"""
|
|
16
|
+
Model repository operations: list, create (container/BYOM),
|
|
17
|
+
create private compile, delete, and profile generation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
21
|
+
self._http = http
|
|
22
|
+
|
|
23
|
+
def list(self, params: ModelRepoListParams, *, trace_id: str | None = None) -> Any:
|
|
24
|
+
return self._http.request(
|
|
25
|
+
"GET",
|
|
26
|
+
"/models/client/model-repos/",
|
|
27
|
+
params=params.to_params(),
|
|
28
|
+
trace_id=trace_id,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def create(self, model_repo: ModelRepoCreate, *, trace_id: str | None = None) -> Dict[str, Any]:
|
|
32
|
+
"""Create a model repo via client endpoint (container/BYOM flow)."""
|
|
33
|
+
return self._http.request(
|
|
34
|
+
"POST",
|
|
35
|
+
"/models/client/model-repos/",
|
|
36
|
+
json=model_repo.to_payload(),
|
|
37
|
+
trace_id=trace_id,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def create_private_compile(
|
|
41
|
+
self,
|
|
42
|
+
payload: ModelRepoCompileCreate,
|
|
43
|
+
*,
|
|
44
|
+
trace_id: str | None = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Create a private compile model repo.
|
|
48
|
+
|
|
49
|
+
This path always sends `use_simplismart_infrastructure=true`.
|
|
50
|
+
"""
|
|
51
|
+
body = payload.to_payload()
|
|
52
|
+
body["use_simplismart_infrastructure"] = True
|
|
53
|
+
return self._http.request(
|
|
54
|
+
"POST",
|
|
55
|
+
"/models/client/model-repos/",
|
|
56
|
+
json=body,
|
|
57
|
+
trace_id=trace_id,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def profiles(self, payload: ModelRepoProfilesRequest, *, trace_id: str | None = None) -> Any:
|
|
61
|
+
return self._http.request(
|
|
62
|
+
"POST",
|
|
63
|
+
"/models/model-profiles/",
|
|
64
|
+
json=payload.to_payload(),
|
|
65
|
+
trace_id=trace_id,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def delete(self, model_id: str, *, trace_id: str | None = None) -> bool:
|
|
69
|
+
self._http.request(
|
|
70
|
+
"DELETE",
|
|
71
|
+
"/models/client/model-repos/",
|
|
72
|
+
params={"model_id": model_id},
|
|
73
|
+
trace_id=trace_id,
|
|
74
|
+
)
|
|
75
|
+
return True
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from ..http import HTTPClient
|
|
6
|
+
from ..models.secret import SecretCreate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SecretAPI:
|
|
10
|
+
"""
|
|
11
|
+
Secret operations. Uses PG token auth.
|
|
12
|
+
|
|
13
|
+
Notes:
|
|
14
|
+
- Secret config endpoint derives org context from pgToken.
|
|
15
|
+
- Secret list endpoint still requires explicit org_id query parameter on backend.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
19
|
+
self._http = http
|
|
20
|
+
|
|
21
|
+
def create(self, payload: SecretCreate, *, trace_id: str | None = None) -> Dict[str, Any]:
|
|
22
|
+
return self._http.request(
|
|
23
|
+
"POST",
|
|
24
|
+
"/accounts/secrets/",
|
|
25
|
+
json=payload.to_payload(),
|
|
26
|
+
trace_id=trace_id,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def list(self, *, org_id: str, trace_id: str | None = None) -> Any:
|
|
30
|
+
return self._http.request(
|
|
31
|
+
"GET",
|
|
32
|
+
"/accounts/secrets/",
|
|
33
|
+
params={"org_id": org_id},
|
|
34
|
+
trace_id=trace_id,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def get(self, secret_id: str, *, trace_id: str | None = None) -> Any:
|
|
38
|
+
return self._http.request("GET", f"/accounts/secrets/{secret_id}/", trace_id=trace_id)
|
|
39
|
+
|
|
40
|
+
def list_configs(self, *, trace_id: str | None = None) -> Any:
|
|
41
|
+
return self._http.request("GET", "/accounts/secret/config/", trace_id=trace_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for Simplismart."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .. import Simplismart, SimplismartError
|
|
9
|
+
from ..config import DEFAULT_TIMEOUT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.app.simplismart.ai"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_env_list(items: Optional[List[str]]) -> Optional[Dict[str, str]]:
|
|
16
|
+
"""Convert KEY=VALUE list into a dict."""
|
|
17
|
+
if not items:
|
|
18
|
+
return None
|
|
19
|
+
env: Dict[str, str] = {}
|
|
20
|
+
for item in items:
|
|
21
|
+
if "=" not in item:
|
|
22
|
+
raise argparse.ArgumentTypeError(f"Invalid env entry '{item}', expected KEY=VALUE.")
|
|
23
|
+
key, value = item.split("=", 1)
|
|
24
|
+
env[key] = value
|
|
25
|
+
return env
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_json_or_file(value: Optional[str]) -> Optional[Any]:
|
|
29
|
+
"""Allow passing JSON string or @path to JSON file."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if value.startswith("@"):
|
|
33
|
+
path = value[1:]
|
|
34
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
35
|
+
return json.load(f)
|
|
36
|
+
return json.loads(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_client(args: argparse.Namespace) -> Simplismart:
|
|
40
|
+
token = args.pg_token or os.getenv("SIMPLISMART_PG_TOKEN")
|
|
41
|
+
base_url = args.base_url or os.getenv("SIMPLISMART_BASE_URL", DEFAULT_BASE_URL)
|
|
42
|
+
|
|
43
|
+
timeout_value = args.timeout
|
|
44
|
+
if timeout_value is None:
|
|
45
|
+
env_timeout = os.getenv("SIMPLISMART_TIMEOUT")
|
|
46
|
+
timeout_value = float(env_timeout) if env_timeout else None
|
|
47
|
+
|
|
48
|
+
return Simplismart(
|
|
49
|
+
pg_token=token,
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
timeout=timeout_value,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def handle_cli_errors(func):
|
|
56
|
+
"""Decorator to wrap CLI handlers with consistent error output."""
|
|
57
|
+
|
|
58
|
+
def wrapper(args: argparse.Namespace) -> None:
|
|
59
|
+
try:
|
|
60
|
+
func(args)
|
|
61
|
+
except SimplismartError as exc:
|
|
62
|
+
payload = getattr(exc, "payload", None)
|
|
63
|
+
status = getattr(exc, "status_code", None)
|
|
64
|
+
message = str(exc.args[0]) if getattr(exc, "args", None) else str(exc)
|
|
65
|
+
|
|
66
|
+
response_body = {
|
|
67
|
+
"error": message,
|
|
68
|
+
"status": status,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
payload_has_only_message = False
|
|
72
|
+
if isinstance(payload, dict):
|
|
73
|
+
candidate = (
|
|
74
|
+
payload.get("detail")
|
|
75
|
+
or payload.get("error")
|
|
76
|
+
or payload.get("message")
|
|
77
|
+
)
|
|
78
|
+
payload_has_only_message = (
|
|
79
|
+
candidate is not None
|
|
80
|
+
and str(candidate) == message
|
|
81
|
+
and len(payload) == 1
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if payload and not payload_has_only_message:
|
|
85
|
+
response_body["payload"] = payload
|
|
86
|
+
|
|
87
|
+
print(json.dumps(response_body, indent=2, default=str))
|
|
88
|
+
raise SystemExit(1)
|
|
89
|
+
except Exception as exc: # pragma: no cover - CLI safeguard
|
|
90
|
+
print(json.dumps({"error": str(exc)}))
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
|
|
93
|
+
return wrapper
|