tetra-rp 0.6.0__py3-none-any.whl → 0.24.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.
- tetra_rp/__init__.py +109 -19
- tetra_rp/cli/commands/__init__.py +1 -0
- tetra_rp/cli/commands/apps.py +143 -0
- tetra_rp/cli/commands/build.py +1082 -0
- tetra_rp/cli/commands/build_utils/__init__.py +1 -0
- tetra_rp/cli/commands/build_utils/handler_generator.py +176 -0
- tetra_rp/cli/commands/build_utils/lb_handler_generator.py +309 -0
- tetra_rp/cli/commands/build_utils/manifest.py +430 -0
- tetra_rp/cli/commands/build_utils/mothership_handler_generator.py +75 -0
- tetra_rp/cli/commands/build_utils/scanner.py +596 -0
- tetra_rp/cli/commands/deploy.py +580 -0
- tetra_rp/cli/commands/init.py +123 -0
- tetra_rp/cli/commands/resource.py +108 -0
- tetra_rp/cli/commands/run.py +296 -0
- tetra_rp/cli/commands/test_mothership.py +458 -0
- tetra_rp/cli/commands/undeploy.py +533 -0
- tetra_rp/cli/main.py +97 -0
- tetra_rp/cli/utils/__init__.py +1 -0
- tetra_rp/cli/utils/app.py +15 -0
- tetra_rp/cli/utils/conda.py +127 -0
- tetra_rp/cli/utils/deployment.py +530 -0
- tetra_rp/cli/utils/ignore.py +143 -0
- tetra_rp/cli/utils/skeleton.py +184 -0
- tetra_rp/cli/utils/skeleton_template/.env.example +4 -0
- tetra_rp/cli/utils/skeleton_template/.flashignore +40 -0
- tetra_rp/cli/utils/skeleton_template/.gitignore +44 -0
- tetra_rp/cli/utils/skeleton_template/README.md +263 -0
- tetra_rp/cli/utils/skeleton_template/main.py +44 -0
- tetra_rp/cli/utils/skeleton_template/mothership.py +55 -0
- tetra_rp/cli/utils/skeleton_template/pyproject.toml +58 -0
- tetra_rp/cli/utils/skeleton_template/requirements.txt +1 -0
- tetra_rp/cli/utils/skeleton_template/workers/__init__.py +0 -0
- tetra_rp/cli/utils/skeleton_template/workers/cpu/__init__.py +19 -0
- tetra_rp/cli/utils/skeleton_template/workers/cpu/endpoint.py +36 -0
- tetra_rp/cli/utils/skeleton_template/workers/gpu/__init__.py +19 -0
- tetra_rp/cli/utils/skeleton_template/workers/gpu/endpoint.py +61 -0
- tetra_rp/client.py +136 -33
- tetra_rp/config.py +29 -0
- tetra_rp/core/api/runpod.py +591 -39
- tetra_rp/core/deployment.py +232 -0
- tetra_rp/core/discovery.py +425 -0
- tetra_rp/core/exceptions.py +50 -0
- tetra_rp/core/resources/__init__.py +27 -9
- tetra_rp/core/resources/app.py +738 -0
- tetra_rp/core/resources/base.py +139 -4
- tetra_rp/core/resources/constants.py +21 -0
- tetra_rp/core/resources/cpu.py +115 -13
- tetra_rp/core/resources/gpu.py +182 -16
- tetra_rp/core/resources/live_serverless.py +153 -16
- tetra_rp/core/resources/load_balancer_sls_resource.py +440 -0
- tetra_rp/core/resources/network_volume.py +126 -31
- tetra_rp/core/resources/resource_manager.py +436 -35
- tetra_rp/core/resources/serverless.py +537 -120
- tetra_rp/core/resources/serverless_cpu.py +201 -0
- tetra_rp/core/resources/template.py +1 -59
- tetra_rp/core/utils/constants.py +10 -0
- tetra_rp/core/utils/file_lock.py +260 -0
- tetra_rp/core/utils/http.py +67 -0
- tetra_rp/core/utils/lru_cache.py +75 -0
- tetra_rp/core/utils/singleton.py +36 -1
- tetra_rp/core/validation.py +44 -0
- tetra_rp/execute_class.py +301 -0
- tetra_rp/protos/remote_execution.py +98 -9
- tetra_rp/runtime/__init__.py +1 -0
- tetra_rp/runtime/circuit_breaker.py +274 -0
- tetra_rp/runtime/config.py +12 -0
- tetra_rp/runtime/exceptions.py +49 -0
- tetra_rp/runtime/generic_handler.py +206 -0
- tetra_rp/runtime/lb_handler.py +189 -0
- tetra_rp/runtime/load_balancer.py +160 -0
- tetra_rp/runtime/manifest_fetcher.py +192 -0
- tetra_rp/runtime/metrics.py +325 -0
- tetra_rp/runtime/models.py +73 -0
- tetra_rp/runtime/mothership_provisioner.py +512 -0
- tetra_rp/runtime/production_wrapper.py +266 -0
- tetra_rp/runtime/reliability_config.py +149 -0
- tetra_rp/runtime/retry_manager.py +118 -0
- tetra_rp/runtime/serialization.py +124 -0
- tetra_rp/runtime/service_registry.py +346 -0
- tetra_rp/runtime/state_manager_client.py +248 -0
- tetra_rp/stubs/live_serverless.py +35 -17
- tetra_rp/stubs/load_balancer_sls.py +357 -0
- tetra_rp/stubs/registry.py +145 -19
- {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/METADATA +398 -60
- tetra_rp-0.24.0.dist-info/RECORD +99 -0
- {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/WHEEL +1 -1
- tetra_rp-0.24.0.dist-info/entry_points.txt +2 -0
- tetra_rp/core/pool/cluster_manager.py +0 -177
- tetra_rp/core/pool/dataclass.py +0 -18
- tetra_rp/core/pool/ex.py +0 -38
- tetra_rp/core/pool/job.py +0 -22
- tetra_rp/core/pool/worker.py +0 -19
- tetra_rp/core/resources/utils.py +0 -50
- tetra_rp/core/utils/json.py +0 -33
- tetra_rp-0.6.0.dist-info/RECORD +0 -39
- /tetra_rp/{core/pool → cli}/__init__.py +0 -0
- {tetra_rp-0.6.0.dist-info → tetra_rp-0.24.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Metrics collection via structured logging for observability."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetricType(Enum):
|
|
12
|
+
"""Types of metrics that can be collected."""
|
|
13
|
+
|
|
14
|
+
COUNTER = "counter"
|
|
15
|
+
GAUGE = "gauge"
|
|
16
|
+
HISTOGRAM = "histogram"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Metric:
|
|
21
|
+
"""Representation of a single metric."""
|
|
22
|
+
|
|
23
|
+
metric_type: MetricType
|
|
24
|
+
metric_name: str
|
|
25
|
+
value: float
|
|
26
|
+
labels: Dict[str, Any]
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
29
|
+
"""Convert metric to dictionary.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Dictionary representation of metric
|
|
33
|
+
"""
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MetricsCollector:
|
|
38
|
+
"""Collect metrics via structured logging."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, namespace: str = "tetra.metrics", enabled: bool = True):
|
|
41
|
+
"""Initialize metrics collector.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
namespace: Namespace for metrics (used in structured logging)
|
|
45
|
+
enabled: Whether metrics collection is enabled
|
|
46
|
+
"""
|
|
47
|
+
self.namespace = namespace
|
|
48
|
+
self.enabled = enabled
|
|
49
|
+
|
|
50
|
+
def counter(
|
|
51
|
+
self,
|
|
52
|
+
name: str,
|
|
53
|
+
value: float = 1.0,
|
|
54
|
+
labels: Optional[Dict[str, Any]] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Record a counter metric (cumulative).
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name: Name of the metric
|
|
60
|
+
value: Value to add to counter (default: 1.0)
|
|
61
|
+
labels: Optional labels/tags for the metric
|
|
62
|
+
"""
|
|
63
|
+
if not self.enabled:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
metric = Metric(MetricType.COUNTER, name, value, labels or {})
|
|
67
|
+
self._emit(metric)
|
|
68
|
+
|
|
69
|
+
def gauge(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
value: float,
|
|
73
|
+
labels: Optional[Dict[str, Any]] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Record a gauge metric (point-in-time value).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: Name of the metric
|
|
79
|
+
value: Current value of the gauge
|
|
80
|
+
labels: Optional labels/tags for the metric
|
|
81
|
+
"""
|
|
82
|
+
if not self.enabled:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
metric = Metric(MetricType.GAUGE, name, value, labels or {})
|
|
86
|
+
self._emit(metric)
|
|
87
|
+
|
|
88
|
+
def histogram(
|
|
89
|
+
self,
|
|
90
|
+
name: str,
|
|
91
|
+
value: float,
|
|
92
|
+
labels: Optional[Dict[str, Any]] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Record a histogram metric (distribution).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
name: Name of the metric
|
|
98
|
+
value: Value to add to histogram
|
|
99
|
+
labels: Optional labels/tags for the metric
|
|
100
|
+
"""
|
|
101
|
+
if not self.enabled:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
metric = Metric(MetricType.HISTOGRAM, name, value, labels or {})
|
|
105
|
+
self._emit(metric)
|
|
106
|
+
|
|
107
|
+
def _emit(self, metric: Metric) -> None:
|
|
108
|
+
"""Emit metric via structured logging.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
metric: Metric to emit
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
logger.info(
|
|
115
|
+
f"[METRIC] {metric.metric_name}={metric.value}",
|
|
116
|
+
extra={
|
|
117
|
+
"namespace": self.namespace,
|
|
118
|
+
"metric": metric.to_dict(),
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Failed to emit metric {metric.metric_name}: {e}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Global metrics collector instance
|
|
126
|
+
_collector: Optional[MetricsCollector] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_metrics_collector(
|
|
130
|
+
namespace: str = "tetra.metrics", enabled: bool = True
|
|
131
|
+
) -> MetricsCollector:
|
|
132
|
+
"""Get global metrics collector (lazy-loaded).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
namespace: Namespace for metrics
|
|
136
|
+
enabled: Whether metrics collection is enabled
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
MetricsCollector instance
|
|
140
|
+
"""
|
|
141
|
+
global _collector
|
|
142
|
+
if _collector is None:
|
|
143
|
+
_collector = MetricsCollector(namespace=namespace, enabled=enabled)
|
|
144
|
+
return _collector
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def set_metrics_collector(collector: MetricsCollector) -> None:
|
|
148
|
+
"""Set global metrics collector (for testing).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
collector: MetricsCollector instance
|
|
152
|
+
"""
|
|
153
|
+
global _collector
|
|
154
|
+
_collector = collector
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CircuitBreakerMetrics:
|
|
158
|
+
"""Helper for emitting circuit breaker metrics."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, collector: Optional[MetricsCollector] = None):
|
|
161
|
+
"""Initialize circuit breaker metrics helper.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
collector: Optional MetricsCollector instance (uses global if not provided)
|
|
165
|
+
"""
|
|
166
|
+
self.collector = collector or get_metrics_collector()
|
|
167
|
+
|
|
168
|
+
def state_changed(
|
|
169
|
+
self, endpoint_url: str, new_state: str, previous_state: str
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Emit metric when circuit breaker state changes.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
endpoint_url: URL of the endpoint
|
|
175
|
+
new_state: New circuit state
|
|
176
|
+
previous_state: Previous circuit state
|
|
177
|
+
"""
|
|
178
|
+
self.collector.counter(
|
|
179
|
+
"circuit_breaker_state_changes",
|
|
180
|
+
value=1.0,
|
|
181
|
+
labels={
|
|
182
|
+
"endpoint_url": endpoint_url,
|
|
183
|
+
"new_state": new_state,
|
|
184
|
+
"previous_state": previous_state,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def endpoint_requests(self, endpoint_url: str, status: str, count: int = 1) -> None:
|
|
189
|
+
"""Emit metric for endpoint requests.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
endpoint_url: URL of the endpoint
|
|
193
|
+
status: Request status (success, failure, etc.)
|
|
194
|
+
count: Number of requests
|
|
195
|
+
"""
|
|
196
|
+
self.collector.counter(
|
|
197
|
+
"endpoint_requests",
|
|
198
|
+
value=float(count),
|
|
199
|
+
labels={"endpoint_url": endpoint_url, "status": status},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def endpoint_latency(self, endpoint_url: str, latency_ms: float) -> None:
|
|
203
|
+
"""Emit metric for endpoint latency.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
endpoint_url: URL of the endpoint
|
|
207
|
+
latency_ms: Latency in milliseconds
|
|
208
|
+
"""
|
|
209
|
+
self.collector.histogram(
|
|
210
|
+
"endpoint_latency",
|
|
211
|
+
value=latency_ms,
|
|
212
|
+
labels={"endpoint_url": endpoint_url},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def in_flight_requests(self, endpoint_url: str, count: int) -> None:
|
|
216
|
+
"""Emit metric for in-flight requests.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
endpoint_url: URL of the endpoint
|
|
220
|
+
count: Current number of in-flight requests
|
|
221
|
+
"""
|
|
222
|
+
self.collector.gauge(
|
|
223
|
+
"in_flight_requests",
|
|
224
|
+
value=float(count),
|
|
225
|
+
labels={"endpoint_url": endpoint_url},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RetryMetrics:
|
|
230
|
+
"""Helper for emitting retry metrics."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, collector: Optional[MetricsCollector] = None):
|
|
233
|
+
"""Initialize retry metrics helper.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
collector: Optional MetricsCollector instance (uses global if not provided)
|
|
237
|
+
"""
|
|
238
|
+
self.collector = collector or get_metrics_collector()
|
|
239
|
+
|
|
240
|
+
def retry_attempt(
|
|
241
|
+
self, function_name: str, attempt: int, error: Optional[str] = None
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Emit metric for retry attempt.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
function_name: Name of the function being retried
|
|
247
|
+
attempt: Attempt number
|
|
248
|
+
error: Optional error message
|
|
249
|
+
"""
|
|
250
|
+
labels = {
|
|
251
|
+
"function_name": function_name,
|
|
252
|
+
"attempt": str(attempt),
|
|
253
|
+
}
|
|
254
|
+
if error:
|
|
255
|
+
labels["error"] = error
|
|
256
|
+
|
|
257
|
+
self.collector.counter(
|
|
258
|
+
"retry_attempts",
|
|
259
|
+
value=1.0,
|
|
260
|
+
labels=labels,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def retry_success(self, function_name: str, total_attempts: int) -> None:
|
|
264
|
+
"""Emit metric for successful retry.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
function_name: Name of the function
|
|
268
|
+
total_attempts: Total attempts made before success
|
|
269
|
+
"""
|
|
270
|
+
self.collector.counter(
|
|
271
|
+
"retry_success",
|
|
272
|
+
value=1.0,
|
|
273
|
+
labels={
|
|
274
|
+
"function_name": function_name,
|
|
275
|
+
"attempts": str(total_attempts),
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def retry_exhausted(self, function_name: str, max_attempts: int) -> None:
|
|
280
|
+
"""Emit metric when max retries exceeded.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
function_name: Name of the function
|
|
284
|
+
max_attempts: Maximum attempts configured
|
|
285
|
+
"""
|
|
286
|
+
self.collector.counter(
|
|
287
|
+
"retry_exhausted",
|
|
288
|
+
value=1.0,
|
|
289
|
+
labels={
|
|
290
|
+
"function_name": function_name,
|
|
291
|
+
"max_attempts": str(max_attempts),
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class LoadBalancerMetrics:
|
|
297
|
+
"""Helper for emitting load balancer metrics."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, collector: Optional[MetricsCollector] = None):
|
|
300
|
+
"""Initialize load balancer metrics helper.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
collector: Optional MetricsCollector instance (uses global if not provided)
|
|
304
|
+
"""
|
|
305
|
+
self.collector = collector or get_metrics_collector()
|
|
306
|
+
|
|
307
|
+
def endpoint_selected(
|
|
308
|
+
self, strategy: str, endpoint_url: str, total_candidates: int
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Emit metric when endpoint is selected.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
strategy: Load balancing strategy used
|
|
314
|
+
endpoint_url: Selected endpoint URL
|
|
315
|
+
total_candidates: Total candidate endpoints
|
|
316
|
+
"""
|
|
317
|
+
self.collector.counter(
|
|
318
|
+
"load_balancer_selection",
|
|
319
|
+
value=1.0,
|
|
320
|
+
labels={
|
|
321
|
+
"strategy": strategy,
|
|
322
|
+
"endpoint_url": endpoint_url,
|
|
323
|
+
"candidates": str(total_candidates),
|
|
324
|
+
},
|
|
325
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Type-safe models for manifest handling."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class FunctionMetadata:
|
|
9
|
+
"""Function metadata in manifest."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
module: str
|
|
13
|
+
is_async: bool
|
|
14
|
+
is_class: bool = False
|
|
15
|
+
http_method: Optional[str] = None
|
|
16
|
+
http_path: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ResourceConfig:
|
|
21
|
+
"""Resource configuration in manifest."""
|
|
22
|
+
|
|
23
|
+
resource_type: str
|
|
24
|
+
handler_file: str
|
|
25
|
+
functions: List[FunctionMetadata] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ResourceConfig":
|
|
29
|
+
"""Load ResourceConfig from dict."""
|
|
30
|
+
functions = [
|
|
31
|
+
FunctionMetadata(**func_data) for func_data in data.get("functions", [])
|
|
32
|
+
]
|
|
33
|
+
return cls(
|
|
34
|
+
resource_type=data["resource_type"],
|
|
35
|
+
handler_file=data["handler_file"],
|
|
36
|
+
functions=functions,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Manifest:
|
|
42
|
+
"""Type-safe manifest structure."""
|
|
43
|
+
|
|
44
|
+
version: str
|
|
45
|
+
generated_at: str
|
|
46
|
+
project_name: str
|
|
47
|
+
function_registry: Dict[str, str]
|
|
48
|
+
resources: Dict[str, ResourceConfig]
|
|
49
|
+
routes: Optional[Dict[str, Dict[str, str]]] = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Manifest":
|
|
53
|
+
"""Load Manifest from JSON dict."""
|
|
54
|
+
resources = {}
|
|
55
|
+
for resource_name, resource_data in data.get("resources", {}).items():
|
|
56
|
+
resources[resource_name] = ResourceConfig.from_dict(resource_data)
|
|
57
|
+
|
|
58
|
+
return cls(
|
|
59
|
+
version=data.get("version", "1.0"),
|
|
60
|
+
generated_at=data.get("generated_at", ""),
|
|
61
|
+
project_name=data.get("project_name", ""),
|
|
62
|
+
function_registry=data.get("function_registry", {}),
|
|
63
|
+
resources=resources,
|
|
64
|
+
routes=data.get("routes"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
"""Convert to JSON-serializable dict."""
|
|
69
|
+
result = asdict(self)
|
|
70
|
+
# Remove None routes to keep JSON clean
|
|
71
|
+
if result.get("routes") is None:
|
|
72
|
+
result.pop("routes", None)
|
|
73
|
+
return result
|