sixtyseven 0.1.0__cp312-cp312-macosx_11_0_arm64.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.
- sixtyseven-0.1.0.data/purelib/sixtyseven/__init__.py +36 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/cli.py +64 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/client.py +190 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/config.py +161 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/exceptions.py +40 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/local.py +335 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/metrics.py +157 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/run.py +445 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/server.py +383 -0
- sixtyseven-0.1.0.data/purelib/sixtyseven/utils.py +171 -0
- sixtyseven-0.1.0.dist-info/METADATA +84 -0
- sixtyseven-0.1.0.dist-info/RECORD +15 -0
- sixtyseven-0.1.0.dist-info/WHEEL +5 -0
- sixtyseven-0.1.0.dist-info/entry_points.txt +2 -0
- sixtyseven-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sixtyseven - ML Experiment Tracking SDK
|
|
3
|
+
|
|
4
|
+
A Python SDK for tracking machine learning experiments with Sixtyseven.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from sixtyseven import Run
|
|
8
|
+
|
|
9
|
+
with Run(project="my-team/image-classifier") as run:
|
|
10
|
+
run.log_config({"learning_rate": 0.001})
|
|
11
|
+
|
|
12
|
+
for epoch in range(100):
|
|
13
|
+
loss = train()
|
|
14
|
+
run.log_metrics({"loss": loss}, step=epoch)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from sixtyseven.config import configure
|
|
18
|
+
from sixtyseven.exceptions import (
|
|
19
|
+
APIError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ServerError,
|
|
22
|
+
SixtySevenError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
from sixtyseven.run import Run
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Run",
|
|
30
|
+
"configure",
|
|
31
|
+
"SixtySevenError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"APIError",
|
|
34
|
+
"ValidationError",
|
|
35
|
+
"ServerError",
|
|
36
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Console entrypoint for the bundled sixtyseven CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _platform_id() -> Optional[str]:
|
|
14
|
+
system = platform.system().lower()
|
|
15
|
+
machine = platform.machine().lower()
|
|
16
|
+
|
|
17
|
+
if machine in {"x86_64", "amd64"}:
|
|
18
|
+
arch = "amd64"
|
|
19
|
+
elif machine in {"aarch64", "arm64"}:
|
|
20
|
+
arch = "arm64"
|
|
21
|
+
else:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
if system == "darwin":
|
|
25
|
+
return f"darwin-{arch}"
|
|
26
|
+
if system == "linux":
|
|
27
|
+
return f"linux-{arch}"
|
|
28
|
+
if system == "windows":
|
|
29
|
+
return f"windows-{arch}"
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bundled_binary_path() -> Optional[str]:
|
|
34
|
+
platform_id = _platform_id()
|
|
35
|
+
if not platform_id:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
binary_name = "sixtyseven.exe" if platform.system() == "Windows" else "sixtyseven"
|
|
39
|
+
base_dir = Path(__file__).resolve().parent
|
|
40
|
+
candidate = base_dir / "bin" / platform_id / binary_name
|
|
41
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
42
|
+
return str(candidate)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_binary() -> Optional[str]:
|
|
47
|
+
bundled = _bundled_binary_path()
|
|
48
|
+
if bundled:
|
|
49
|
+
return bundled
|
|
50
|
+
|
|
51
|
+
binary_name = "sixtyseven.exe" if platform.system() == "Windows" else "sixtyseven"
|
|
52
|
+
return shutil.which(binary_name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
binary = _find_binary()
|
|
57
|
+
if not binary:
|
|
58
|
+
print(
|
|
59
|
+
"Could not find 'sixtyseven' binary. Reinstall the package or set SIXTYSEVEN_BINARY.",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
|
|
64
|
+
os.execv(binary, [binary, *sys.argv[1:]])
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""HTTP client for communicating with the Sixtyseven API."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from sixtyseven.config import SDKConfig
|
|
10
|
+
from sixtyseven.exceptions import APIError, AuthenticationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SixtySevenClient:
|
|
14
|
+
"""HTTP client for the Sixtyseven API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: SDKConfig, api_key: Optional[str] = None):
|
|
17
|
+
"""
|
|
18
|
+
Initialize the client.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
config: SDK configuration
|
|
22
|
+
api_key: API key (overrides config)
|
|
23
|
+
"""
|
|
24
|
+
self.config = config
|
|
25
|
+
self.api_key = api_key or config.api_key
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
|
|
28
|
+
if self.api_key:
|
|
29
|
+
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
30
|
+
|
|
31
|
+
self.session.headers["Content-Type"] = "application/json"
|
|
32
|
+
self.session.headers["User-Agent"] = "sixtyseven-python/0.1.0"
|
|
33
|
+
|
|
34
|
+
def _url(self, path: str) -> str:
|
|
35
|
+
"""Build full URL for an API endpoint."""
|
|
36
|
+
return urljoin(self.config.base_url, f"/api/v1{path}")
|
|
37
|
+
|
|
38
|
+
def _request(
|
|
39
|
+
self,
|
|
40
|
+
method: str,
|
|
41
|
+
path: str,
|
|
42
|
+
data: Optional[Dict[str, Any]] = None,
|
|
43
|
+
params: Optional[Dict[str, Any]] = None,
|
|
44
|
+
) -> Dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Make an HTTP request with retry logic.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
method: HTTP method
|
|
50
|
+
path: API path
|
|
51
|
+
data: Request body data
|
|
52
|
+
params: Query parameters
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Response JSON data
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
AuthenticationError: If authentication fails
|
|
59
|
+
APIError: If the request fails
|
|
60
|
+
"""
|
|
61
|
+
url = self._url(path)
|
|
62
|
+
last_error = None
|
|
63
|
+
|
|
64
|
+
for attempt in range(self.config.retry_count):
|
|
65
|
+
try:
|
|
66
|
+
response = self.session.request(
|
|
67
|
+
method=method,
|
|
68
|
+
url=url,
|
|
69
|
+
json=data,
|
|
70
|
+
params=params,
|
|
71
|
+
timeout=self.config.timeout,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if response.status_code == 401:
|
|
75
|
+
raise AuthenticationError("Invalid API key")
|
|
76
|
+
|
|
77
|
+
if response.status_code == 403:
|
|
78
|
+
raise AuthenticationError("Insufficient permissions")
|
|
79
|
+
|
|
80
|
+
if response.status_code >= 400:
|
|
81
|
+
error_data = response.json() if response.text else {}
|
|
82
|
+
raise APIError(
|
|
83
|
+
error_data.get(
|
|
84
|
+
"error",
|
|
85
|
+
f"Request failed with status {response.status_code}",
|
|
86
|
+
),
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
response=error_data,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if response.text:
|
|
92
|
+
return response.json()
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
except requests.exceptions.RequestException as e:
|
|
96
|
+
last_error = e
|
|
97
|
+
if attempt < self.config.retry_count - 1:
|
|
98
|
+
time.sleep(self.config.retry_delay * (attempt + 1))
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
raise APIError(
|
|
102
|
+
f"Request failed after {self.config.retry_count} attempts: {last_error}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def create_run(
|
|
106
|
+
self,
|
|
107
|
+
team_slug: str,
|
|
108
|
+
app_slug: str,
|
|
109
|
+
name: Optional[str] = None,
|
|
110
|
+
tags: Optional[List[str]] = None,
|
|
111
|
+
config: Optional[Dict[str, Any]] = None,
|
|
112
|
+
git_info: Optional[Dict[str, Any]] = None,
|
|
113
|
+
system_info: Optional[Dict[str, Any]] = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Create a new run.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The run ID
|
|
120
|
+
"""
|
|
121
|
+
data = {
|
|
122
|
+
"name": name,
|
|
123
|
+
"tags": tags or [],
|
|
124
|
+
"config": config or {},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if git_info:
|
|
128
|
+
data["git_info"] = git_info
|
|
129
|
+
if system_info:
|
|
130
|
+
data["system_info"] = system_info
|
|
131
|
+
|
|
132
|
+
response = self._request(
|
|
133
|
+
"POST",
|
|
134
|
+
f"/teams/{team_slug}/apps/{app_slug}/runs",
|
|
135
|
+
data=data,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return response["id"]
|
|
139
|
+
|
|
140
|
+
def update_run_status(
|
|
141
|
+
self,
|
|
142
|
+
run_id: str,
|
|
143
|
+
status: str,
|
|
144
|
+
error: Optional[str] = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Update run status (completed, failed, aborted)."""
|
|
147
|
+
data = {"status": status}
|
|
148
|
+
if error:
|
|
149
|
+
data["error_message"] = error
|
|
150
|
+
|
|
151
|
+
self._request("PUT", f"/runs/{run_id}/status", data=data)
|
|
152
|
+
|
|
153
|
+
def update_run_config(self, run_id: str, config: Dict[str, Any]) -> None:
|
|
154
|
+
"""Merge new config with existing run config."""
|
|
155
|
+
self._request("PUT", f"/runs/{run_id}/config", data=config)
|
|
156
|
+
|
|
157
|
+
def batch_log_metrics(
|
|
158
|
+
self,
|
|
159
|
+
run_id: str,
|
|
160
|
+
metrics: List[Dict[str, Any]],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Log a batch of metrics.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
run_id: The run ID
|
|
167
|
+
metrics: List of metric dictionaries with name, value, step, timestamp
|
|
168
|
+
"""
|
|
169
|
+
self._request(
|
|
170
|
+
"POST",
|
|
171
|
+
f"/runs/{run_id}/metrics",
|
|
172
|
+
data={"metrics": metrics},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def add_run_tags(self, run_id: str, tags: List[str]) -> None:
|
|
176
|
+
"""Add tags to a run."""
|
|
177
|
+
# This would need to be implemented via the update endpoint
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def upload_artifact(
|
|
181
|
+
self,
|
|
182
|
+
run_id: str,
|
|
183
|
+
path: str,
|
|
184
|
+
name: Optional[str] = None,
|
|
185
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Upload an artifact file."""
|
|
188
|
+
# Artifact upload would need multipart form handling
|
|
189
|
+
# For now, this is a placeholder
|
|
190
|
+
pass
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Configuration management for the Sixtyseven SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _default_logdir() -> str:
|
|
10
|
+
"""Get the default log directory based on platform."""
|
|
11
|
+
import platform
|
|
12
|
+
|
|
13
|
+
home = Path.home()
|
|
14
|
+
system = platform.system()
|
|
15
|
+
|
|
16
|
+
if system == "Darwin":
|
|
17
|
+
return str(home / "Library" / "Application Support" / "sixtyseven" / "logs")
|
|
18
|
+
elif system == "Windows":
|
|
19
|
+
appdata = os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local"))
|
|
20
|
+
return str(Path(appdata) / "sixtyseven" / "logs")
|
|
21
|
+
else: # Linux and others
|
|
22
|
+
xdg_data = os.environ.get("XDG_DATA_HOME", str(home / ".local" / "share"))
|
|
23
|
+
return str(Path(xdg_data) / "sixtyseven" / "logs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SDKConfig:
|
|
28
|
+
"""SDK configuration settings."""
|
|
29
|
+
|
|
30
|
+
# Mode: "local" or "remote"
|
|
31
|
+
mode: Literal["local", "remote"] = "local"
|
|
32
|
+
|
|
33
|
+
# Remote mode settings
|
|
34
|
+
base_url: str = "http://localhost:8080"
|
|
35
|
+
api_key: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
# Local mode settings
|
|
38
|
+
logdir: str = field(default_factory=_default_logdir)
|
|
39
|
+
|
|
40
|
+
# Common settings
|
|
41
|
+
batch_size: int = 100
|
|
42
|
+
flush_interval: float = 5.0
|
|
43
|
+
capture_git: bool = True
|
|
44
|
+
capture_system: bool = True
|
|
45
|
+
|
|
46
|
+
# Remote-only settings
|
|
47
|
+
timeout: int = 30
|
|
48
|
+
retry_count: int = 3
|
|
49
|
+
retry_delay: float = 1.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Global configuration instance
|
|
53
|
+
_config = SDKConfig()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configure(
|
|
57
|
+
mode: Optional[Literal["local", "remote"]] = None,
|
|
58
|
+
base_url: Optional[str] = None,
|
|
59
|
+
api_key: Optional[str] = None,
|
|
60
|
+
logdir: Optional[str] = None,
|
|
61
|
+
batch_size: Optional[int] = None,
|
|
62
|
+
flush_interval: Optional[float] = None,
|
|
63
|
+
capture_git: Optional[bool] = None,
|
|
64
|
+
capture_system: Optional[bool] = None,
|
|
65
|
+
timeout: Optional[int] = None,
|
|
66
|
+
retry_count: Optional[int] = None,
|
|
67
|
+
retry_delay: Optional[float] = None,
|
|
68
|
+
) -> SDKConfig:
|
|
69
|
+
"""
|
|
70
|
+
Configure the SDK globally.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
mode: Operating mode ("local" for file-based, "remote" for API server)
|
|
74
|
+
base_url: Base URL for the Sixtyseven API server (remote mode)
|
|
75
|
+
api_key: API key for authentication (remote mode)
|
|
76
|
+
logdir: Directory for storing logs (local mode)
|
|
77
|
+
batch_size: Number of metrics to batch before sending/writing
|
|
78
|
+
flush_interval: Seconds between automatic flushes
|
|
79
|
+
capture_git: Whether to capture git information
|
|
80
|
+
capture_system: Whether to capture system information
|
|
81
|
+
timeout: Request timeout in seconds (remote mode)
|
|
82
|
+
retry_count: Number of retries for failed requests (remote mode)
|
|
83
|
+
retry_delay: Delay between retries in seconds (remote mode)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The updated configuration
|
|
87
|
+
|
|
88
|
+
Example (local mode - default):
|
|
89
|
+
from sixtyseven import configure
|
|
90
|
+
|
|
91
|
+
configure(logdir="./logs") # Use local file storage
|
|
92
|
+
|
|
93
|
+
Example (remote mode):
|
|
94
|
+
from sixtyseven import configure
|
|
95
|
+
|
|
96
|
+
configure(
|
|
97
|
+
mode="remote",
|
|
98
|
+
base_url="https://api.sixtyseven.ai",
|
|
99
|
+
api_key="ss67_xxxx",
|
|
100
|
+
)
|
|
101
|
+
"""
|
|
102
|
+
global _config
|
|
103
|
+
|
|
104
|
+
if mode is not None:
|
|
105
|
+
_config.mode = mode
|
|
106
|
+
if base_url is not None:
|
|
107
|
+
_config.base_url = base_url
|
|
108
|
+
if api_key is not None:
|
|
109
|
+
_config.api_key = api_key
|
|
110
|
+
if logdir is not None:
|
|
111
|
+
_config.logdir = logdir
|
|
112
|
+
if batch_size is not None:
|
|
113
|
+
_config.batch_size = batch_size
|
|
114
|
+
if flush_interval is not None:
|
|
115
|
+
_config.flush_interval = flush_interval
|
|
116
|
+
if capture_git is not None:
|
|
117
|
+
_config.capture_git = capture_git
|
|
118
|
+
if capture_system is not None:
|
|
119
|
+
_config.capture_system = capture_system
|
|
120
|
+
if timeout is not None:
|
|
121
|
+
_config.timeout = timeout
|
|
122
|
+
if retry_count is not None:
|
|
123
|
+
_config.retry_count = retry_count
|
|
124
|
+
if retry_delay is not None:
|
|
125
|
+
_config.retry_delay = retry_delay
|
|
126
|
+
|
|
127
|
+
return _config
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _detect_mode() -> Literal["local", "remote"]:
|
|
131
|
+
"""
|
|
132
|
+
Auto-detect the operating mode based on environment variables.
|
|
133
|
+
|
|
134
|
+
Priority:
|
|
135
|
+
1. SIXTYSEVEN_LOGDIR set -> local mode
|
|
136
|
+
2. SIXTYSEVEN_URL or SIXTYSEVEN_API_KEY set -> remote mode
|
|
137
|
+
3. Default -> local mode (zero config experience)
|
|
138
|
+
"""
|
|
139
|
+
if os.environ.get("SIXTYSEVEN_LOGDIR"):
|
|
140
|
+
return "local"
|
|
141
|
+
if os.environ.get("SIXTYSEVEN_URL") or os.environ.get("SIXTYSEVEN_API_KEY"):
|
|
142
|
+
return "remote"
|
|
143
|
+
return "local"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_config() -> SDKConfig:
|
|
147
|
+
"""Get the current SDK configuration with environment variable overrides."""
|
|
148
|
+
global _config
|
|
149
|
+
|
|
150
|
+
# Auto-detect mode from environment
|
|
151
|
+
_config.mode = _detect_mode()
|
|
152
|
+
|
|
153
|
+
# Override with environment variables
|
|
154
|
+
if os.environ.get("SIXTYSEVEN_LOGDIR"):
|
|
155
|
+
_config.logdir = os.environ["SIXTYSEVEN_LOGDIR"]
|
|
156
|
+
if os.environ.get("SIXTYSEVEN_URL"):
|
|
157
|
+
_config.base_url = os.environ["SIXTYSEVEN_URL"]
|
|
158
|
+
if os.environ.get("SIXTYSEVEN_API_KEY"):
|
|
159
|
+
_config.api_key = os.environ["SIXTYSEVEN_API_KEY"]
|
|
160
|
+
|
|
161
|
+
return _config
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Custom exceptions for the Sixtyseven SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SixtySevenError(Exception):
|
|
5
|
+
"""Base exception for Sixtyseven SDK errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(SixtySevenError):
|
|
11
|
+
"""Raised when authentication fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(SixtySevenError):
|
|
17
|
+
"""Raised when an API request fails."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, status_code: int = None, response: dict = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.response = response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(SixtySevenError):
|
|
26
|
+
"""Raised when validation fails."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConnectionError(SixtySevenError):
|
|
32
|
+
"""Raised when connection to the server fails."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerError(SixtySevenError):
|
|
38
|
+
"""Raised when server management fails (e.g., binary not found, failed to start)."""
|
|
39
|
+
|
|
40
|
+
pass
|