zlayer-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.
- zlayer/__init__.py +1563 -0
- zlayer/py.typed +0 -0
- zlayer_sdk-0.1.0.dist-info/METADATA +107 -0
- zlayer_sdk-0.1.0.dist-info/RECORD +5 -0
- zlayer_sdk-0.1.0.dist-info/WHEEL +4 -0
zlayer/__init__.py
ADDED
|
@@ -0,0 +1,1563 @@
|
|
|
1
|
+
"""ZLayer SDK for building WASM plugins in Python.
|
|
2
|
+
|
|
3
|
+
This package provides helper functions that wrap the generated WIT bindings
|
|
4
|
+
for easier access to ZLayer host capabilities including configuration,
|
|
5
|
+
key-value storage, logging, secrets, and metrics.
|
|
6
|
+
|
|
7
|
+
The actual WIT bindings are generated by componentize-py at build time.
|
|
8
|
+
This module provides ergonomic Python wrappers around those generated bindings.
|
|
9
|
+
|
|
10
|
+
Example usage:
|
|
11
|
+
from zlayer import (
|
|
12
|
+
get_config,
|
|
13
|
+
kv_get_str,
|
|
14
|
+
log_info,
|
|
15
|
+
ZLayerPlugin,
|
|
16
|
+
PluginInfo,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class MyPlugin(ZLayerPlugin):
|
|
20
|
+
def init(self) -> None:
|
|
21
|
+
api_key = get_config("api_key")
|
|
22
|
+
log_info(f"Plugin initialized with key: {api_key[:4]}...")
|
|
23
|
+
|
|
24
|
+
def info(self) -> PluginInfo:
|
|
25
|
+
return PluginInfo(
|
|
26
|
+
id="mycompany:my-plugin",
|
|
27
|
+
name="My Plugin",
|
|
28
|
+
version="1.0.0",
|
|
29
|
+
description="A sample ZLayer plugin",
|
|
30
|
+
author="My Company",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def handle(self, request: PluginRequest) -> HandleResult:
|
|
34
|
+
return HandleResult.pass_through()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
from abc import ABC, abstractmethod
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from enum import IntEnum
|
|
43
|
+
from typing import Any, Callable, TypeVar
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Version
|
|
48
|
+
"__version__",
|
|
49
|
+
# Config helpers
|
|
50
|
+
"get_config",
|
|
51
|
+
"get_config_required",
|
|
52
|
+
"get_config_bool",
|
|
53
|
+
"get_config_int",
|
|
54
|
+
"get_config_float",
|
|
55
|
+
"get_config_many",
|
|
56
|
+
"get_config_prefix",
|
|
57
|
+
"config_exists",
|
|
58
|
+
# KV helpers
|
|
59
|
+
"kv_get",
|
|
60
|
+
"kv_get_str",
|
|
61
|
+
"kv_set",
|
|
62
|
+
"kv_set_str",
|
|
63
|
+
"kv_set_with_ttl",
|
|
64
|
+
"kv_delete",
|
|
65
|
+
"kv_keys",
|
|
66
|
+
"kv_exists",
|
|
67
|
+
"kv_increment",
|
|
68
|
+
"kv_compare_and_swap",
|
|
69
|
+
# Logging
|
|
70
|
+
"LogLevel",
|
|
71
|
+
"log",
|
|
72
|
+
"log_trace",
|
|
73
|
+
"log_debug",
|
|
74
|
+
"log_info",
|
|
75
|
+
"log_warn",
|
|
76
|
+
"log_error",
|
|
77
|
+
"log_structured",
|
|
78
|
+
"is_log_enabled",
|
|
79
|
+
# Secrets
|
|
80
|
+
"get_secret",
|
|
81
|
+
"get_secret_required",
|
|
82
|
+
"secret_exists",
|
|
83
|
+
"list_secret_names",
|
|
84
|
+
# Metrics
|
|
85
|
+
"counter_inc",
|
|
86
|
+
"counter_inc_labeled",
|
|
87
|
+
"gauge_set",
|
|
88
|
+
"gauge_set_labeled",
|
|
89
|
+
"gauge_add",
|
|
90
|
+
"histogram_observe",
|
|
91
|
+
"histogram_observe_labeled",
|
|
92
|
+
"record_duration",
|
|
93
|
+
"record_duration_labeled",
|
|
94
|
+
# Error types
|
|
95
|
+
"ZLayerError",
|
|
96
|
+
"ConfigError",
|
|
97
|
+
"KVError",
|
|
98
|
+
"KVErrorKind",
|
|
99
|
+
"SecretError",
|
|
100
|
+
# Plugin types
|
|
101
|
+
"PluginInfo",
|
|
102
|
+
"Version",
|
|
103
|
+
"KeyValue",
|
|
104
|
+
"Capabilities",
|
|
105
|
+
"HttpMethod",
|
|
106
|
+
"PluginRequest",
|
|
107
|
+
"PluginResponse",
|
|
108
|
+
"HandleResult",
|
|
109
|
+
"InitError",
|
|
110
|
+
"InitErrorKind",
|
|
111
|
+
# Plugin base class
|
|
112
|
+
"ZLayerPlugin",
|
|
113
|
+
# Decorators
|
|
114
|
+
"require_capability",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# =============================================================================
|
|
119
|
+
# Type Variables
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
T = TypeVar("T")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Error Types
|
|
127
|
+
# =============================================================================
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ZLayerError(Exception):
|
|
131
|
+
"""Base exception for all ZLayer errors."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, message: str, code: str = "UNKNOWN") -> None:
|
|
134
|
+
super().__init__(message)
|
|
135
|
+
self.code = code
|
|
136
|
+
self.message = message
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
return f"{self.__class__.__name__}(code={self.code!r}, message={self.message!r})"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConfigError(ZLayerError):
|
|
143
|
+
"""Error accessing configuration values."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, message: str, key: str | None = None) -> None:
|
|
146
|
+
super().__init__(message, code="CONFIG_ERROR")
|
|
147
|
+
self.key = key
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class KVErrorKind(IntEnum):
|
|
151
|
+
"""Kind of key-value error."""
|
|
152
|
+
|
|
153
|
+
NOT_FOUND = 0
|
|
154
|
+
VALUE_TOO_LARGE = 1
|
|
155
|
+
QUOTA_EXCEEDED = 2
|
|
156
|
+
INVALID_KEY = 3
|
|
157
|
+
STORAGE = 4
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class KVError(ZLayerError):
|
|
161
|
+
"""Error accessing key-value storage."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
message: str,
|
|
166
|
+
kind: KVErrorKind = KVErrorKind.STORAGE,
|
|
167
|
+
key: str | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
super().__init__(message, code="KV_ERROR")
|
|
170
|
+
self.kind = kind
|
|
171
|
+
self.key = key
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def not_found(cls, key: str) -> KVError:
|
|
175
|
+
"""Create a not found error."""
|
|
176
|
+
return cls(f"Key not found: {key}", KVErrorKind.NOT_FOUND, key)
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def value_too_large(cls, key: str, size: int) -> KVError:
|
|
180
|
+
"""Create a value too large error."""
|
|
181
|
+
return cls(f"Value too large for key {key}: {size} bytes", KVErrorKind.VALUE_TOO_LARGE, key)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def quota_exceeded(cls) -> KVError:
|
|
185
|
+
"""Create a quota exceeded error."""
|
|
186
|
+
return cls("Storage quota exceeded", KVErrorKind.QUOTA_EXCEEDED)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def invalid_key(cls, key: str) -> KVError:
|
|
190
|
+
"""Create an invalid key error."""
|
|
191
|
+
return cls(f"Invalid key format: {key}", KVErrorKind.INVALID_KEY, key)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SecretError(ZLayerError):
|
|
195
|
+
"""Error accessing secrets."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, message: str, name: str | None = None) -> None:
|
|
198
|
+
super().__init__(message, code="SECRET_ERROR")
|
|
199
|
+
self.name = name
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# =============================================================================
|
|
203
|
+
# Common Types
|
|
204
|
+
# =============================================================================
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class KeyValue:
|
|
209
|
+
"""A key-value pair for metadata, headers, etc."""
|
|
210
|
+
|
|
211
|
+
key: str
|
|
212
|
+
value: str
|
|
213
|
+
|
|
214
|
+
def to_tuple(self) -> tuple[str, str]:
|
|
215
|
+
"""Convert to a tuple."""
|
|
216
|
+
return (self.key, self.value)
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def from_tuple(cls, t: tuple[str, str]) -> KeyValue:
|
|
220
|
+
"""Create from a tuple."""
|
|
221
|
+
return cls(key=t[0], value=t[1])
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_dict(cls, d: dict[str, str]) -> list[KeyValue]:
|
|
225
|
+
"""Convert a dict to a list of KeyValue pairs."""
|
|
226
|
+
return [cls(key=k, value=v) for k, v in d.items()]
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def to_dict(kvs: list[KeyValue]) -> dict[str, str]:
|
|
230
|
+
"""Convert a list of KeyValue pairs to a dict."""
|
|
231
|
+
return {kv.key: kv.value for kv in kvs}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True, slots=True)
|
|
235
|
+
class Version:
|
|
236
|
+
"""Plugin version following semver."""
|
|
237
|
+
|
|
238
|
+
major: int
|
|
239
|
+
minor: int
|
|
240
|
+
patch: int
|
|
241
|
+
pre_release: str | None = None
|
|
242
|
+
|
|
243
|
+
def __str__(self) -> str:
|
|
244
|
+
version = f"{self.major}.{self.minor}.{self.patch}"
|
|
245
|
+
if self.pre_release:
|
|
246
|
+
version += f"-{self.pre_release}"
|
|
247
|
+
return version
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def parse(cls, version_str: str) -> Version:
|
|
251
|
+
"""Parse a version string like '1.2.3' or '1.2.3-beta.1'."""
|
|
252
|
+
pre_release = None
|
|
253
|
+
if "-" in version_str:
|
|
254
|
+
version_str, pre_release = version_str.split("-", 1)
|
|
255
|
+
|
|
256
|
+
parts = version_str.split(".")
|
|
257
|
+
if len(parts) != 3:
|
|
258
|
+
raise ValueError(f"Invalid version format: {version_str}")
|
|
259
|
+
|
|
260
|
+
return cls(
|
|
261
|
+
major=int(parts[0]),
|
|
262
|
+
minor=int(parts[1]),
|
|
263
|
+
patch=int(parts[2]),
|
|
264
|
+
pre_release=pre_release,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class Capabilities:
|
|
269
|
+
"""Plugin capabilities that can be requested.
|
|
270
|
+
|
|
271
|
+
This is a flags class where multiple capabilities can be combined.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
CONFIG = 1 << 0
|
|
275
|
+
KEYVALUE = 1 << 1
|
|
276
|
+
LOGGING = 1 << 2
|
|
277
|
+
SECRETS = 1 << 3
|
|
278
|
+
METRICS = 1 << 4
|
|
279
|
+
HTTP_CLIENT = 1 << 5
|
|
280
|
+
|
|
281
|
+
def __init__(self, flags: int = 0) -> None:
|
|
282
|
+
self._flags = flags
|
|
283
|
+
|
|
284
|
+
def __or__(self, other: Capabilities | int) -> Capabilities:
|
|
285
|
+
if isinstance(other, Capabilities):
|
|
286
|
+
return Capabilities(self._flags | other._flags)
|
|
287
|
+
return Capabilities(self._flags | other)
|
|
288
|
+
|
|
289
|
+
def __and__(self, other: Capabilities | int) -> Capabilities:
|
|
290
|
+
if isinstance(other, Capabilities):
|
|
291
|
+
return Capabilities(self._flags & other._flags)
|
|
292
|
+
return Capabilities(self._flags & other)
|
|
293
|
+
|
|
294
|
+
def __contains__(self, cap: int) -> bool:
|
|
295
|
+
return bool(self._flags & cap)
|
|
296
|
+
|
|
297
|
+
def __repr__(self) -> str:
|
|
298
|
+
caps = []
|
|
299
|
+
if self.CONFIG in self:
|
|
300
|
+
caps.append("CONFIG")
|
|
301
|
+
if self.KEYVALUE in self:
|
|
302
|
+
caps.append("KEYVALUE")
|
|
303
|
+
if self.LOGGING in self:
|
|
304
|
+
caps.append("LOGGING")
|
|
305
|
+
if self.SECRETS in self:
|
|
306
|
+
caps.append("SECRETS")
|
|
307
|
+
if self.METRICS in self:
|
|
308
|
+
caps.append("METRICS")
|
|
309
|
+
if self.HTTP_CLIENT in self:
|
|
310
|
+
caps.append("HTTP_CLIENT")
|
|
311
|
+
return f"Capabilities({' | '.join(caps)})"
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def all(cls) -> Capabilities:
|
|
315
|
+
"""Create a Capabilities object with all capabilities enabled."""
|
|
316
|
+
return Capabilities(
|
|
317
|
+
cls.CONFIG | cls.KEYVALUE | cls.LOGGING | cls.SECRETS | cls.METRICS | cls.HTTP_CLIENT
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def none(cls) -> Capabilities:
|
|
322
|
+
"""Create a Capabilities object with no capabilities."""
|
|
323
|
+
return Capabilities(0)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# =============================================================================
|
|
327
|
+
# Plugin Metadata
|
|
328
|
+
# =============================================================================
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(slots=True)
|
|
332
|
+
class PluginInfo:
|
|
333
|
+
"""Plugin information returned by info().
|
|
334
|
+
|
|
335
|
+
Attributes:
|
|
336
|
+
id: Unique plugin identifier (e.g., "zlayer:auth-jwt")
|
|
337
|
+
name: Human-readable name
|
|
338
|
+
version: Plugin version (string or Version object)
|
|
339
|
+
description: Brief description of plugin functionality
|
|
340
|
+
author: Plugin author or organization
|
|
341
|
+
license: License identifier (e.g., "MIT", "Apache-2.0")
|
|
342
|
+
homepage: Homepage or repository URL
|
|
343
|
+
metadata: Additional metadata as key-value pairs
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
id: str
|
|
347
|
+
name: str
|
|
348
|
+
version: str | Version
|
|
349
|
+
description: str
|
|
350
|
+
author: str
|
|
351
|
+
license: str | None = None
|
|
352
|
+
homepage: str | None = None
|
|
353
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
354
|
+
|
|
355
|
+
def get_version(self) -> Version:
|
|
356
|
+
"""Get the version as a Version object."""
|
|
357
|
+
if isinstance(self.version, Version):
|
|
358
|
+
return self.version
|
|
359
|
+
return Version.parse(self.version)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# =============================================================================
|
|
363
|
+
# Request/Response Types
|
|
364
|
+
# =============================================================================
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class HttpMethod(IntEnum):
|
|
368
|
+
"""HTTP method enum."""
|
|
369
|
+
|
|
370
|
+
GET = 0
|
|
371
|
+
POST = 1
|
|
372
|
+
PUT = 2
|
|
373
|
+
DELETE = 3
|
|
374
|
+
PATCH = 4
|
|
375
|
+
HEAD = 5
|
|
376
|
+
OPTIONS = 6
|
|
377
|
+
CONNECT = 7
|
|
378
|
+
TRACE = 8
|
|
379
|
+
|
|
380
|
+
def __str__(self) -> str:
|
|
381
|
+
return self.name
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(slots=True)
|
|
385
|
+
class PluginRequest:
|
|
386
|
+
"""Incoming request to be processed by a plugin.
|
|
387
|
+
|
|
388
|
+
Attributes:
|
|
389
|
+
request_id: Unique request identifier for tracing
|
|
390
|
+
path: Request path (e.g., "/api/users/123")
|
|
391
|
+
method: HTTP method
|
|
392
|
+
query: Query string (without leading ?)
|
|
393
|
+
headers: Request headers as KeyValue list
|
|
394
|
+
body: Request body as bytes
|
|
395
|
+
timestamp: Request timestamp in nanoseconds since epoch
|
|
396
|
+
context: Additional context from the host
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
request_id: str
|
|
400
|
+
path: str
|
|
401
|
+
method: HttpMethod
|
|
402
|
+
query: str | None
|
|
403
|
+
headers: list[KeyValue]
|
|
404
|
+
body: bytes
|
|
405
|
+
timestamp: int
|
|
406
|
+
context: list[KeyValue]
|
|
407
|
+
|
|
408
|
+
def get_header(self, name: str) -> str | None:
|
|
409
|
+
"""Get a header value by name (case-insensitive)."""
|
|
410
|
+
name_lower = name.lower()
|
|
411
|
+
for kv in self.headers:
|
|
412
|
+
if kv.key.lower() == name_lower:
|
|
413
|
+
return kv.value
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
def get_headers(self, name: str) -> list[str]:
|
|
417
|
+
"""Get all header values for a name (case-insensitive)."""
|
|
418
|
+
name_lower = name.lower()
|
|
419
|
+
return [kv.value for kv in self.headers if kv.key.lower() == name_lower]
|
|
420
|
+
|
|
421
|
+
def get_context(self, key: str) -> str | None:
|
|
422
|
+
"""Get a context value by key."""
|
|
423
|
+
for kv in self.context:
|
|
424
|
+
if kv.key == key:
|
|
425
|
+
return kv.value
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
def body_str(self, encoding: str = "utf-8") -> str:
|
|
429
|
+
"""Get the body as a string."""
|
|
430
|
+
return self.body.decode(encoding)
|
|
431
|
+
|
|
432
|
+
def body_json(self) -> Any:
|
|
433
|
+
"""Parse the body as JSON."""
|
|
434
|
+
return json.loads(self.body)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(slots=True)
|
|
438
|
+
class PluginResponse:
|
|
439
|
+
"""Plugin response returned to the host.
|
|
440
|
+
|
|
441
|
+
Attributes:
|
|
442
|
+
status: HTTP status code (200, 404, 500, etc.)
|
|
443
|
+
headers: Response headers
|
|
444
|
+
body: Response body
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
status: int
|
|
448
|
+
headers: list[KeyValue]
|
|
449
|
+
body: bytes
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def ok(cls, body: bytes | str = b"", headers: dict[str, str] | None = None) -> PluginResponse:
|
|
453
|
+
"""Create a 200 OK response."""
|
|
454
|
+
if isinstance(body, str):
|
|
455
|
+
body = body.encode("utf-8")
|
|
456
|
+
return cls(
|
|
457
|
+
status=200,
|
|
458
|
+
headers=KeyValue.from_dict(headers or {}),
|
|
459
|
+
body=body,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def json(
|
|
464
|
+
cls,
|
|
465
|
+
data: Any,
|
|
466
|
+
status: int = 200,
|
|
467
|
+
headers: dict[str, str] | None = None,
|
|
468
|
+
) -> PluginResponse:
|
|
469
|
+
"""Create a JSON response."""
|
|
470
|
+
h = headers.copy() if headers else {}
|
|
471
|
+
h["Content-Type"] = "application/json"
|
|
472
|
+
return cls(
|
|
473
|
+
status=status,
|
|
474
|
+
headers=KeyValue.from_dict(h),
|
|
475
|
+
body=json.dumps(data).encode("utf-8"),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
@classmethod
|
|
479
|
+
def error(
|
|
480
|
+
cls,
|
|
481
|
+
status: int,
|
|
482
|
+
message: str,
|
|
483
|
+
headers: dict[str, str] | None = None,
|
|
484
|
+
) -> PluginResponse:
|
|
485
|
+
"""Create an error response."""
|
|
486
|
+
h = headers.copy() if headers else {}
|
|
487
|
+
h["Content-Type"] = "application/json"
|
|
488
|
+
return cls(
|
|
489
|
+
status=status,
|
|
490
|
+
headers=KeyValue.from_dict(h),
|
|
491
|
+
body=json.dumps({"error": message}).encode("utf-8"),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def not_found(cls, message: str = "Not Found") -> PluginResponse:
|
|
496
|
+
"""Create a 404 Not Found response."""
|
|
497
|
+
return cls.error(404, message)
|
|
498
|
+
|
|
499
|
+
@classmethod
|
|
500
|
+
def bad_request(cls, message: str = "Bad Request") -> PluginResponse:
|
|
501
|
+
"""Create a 400 Bad Request response."""
|
|
502
|
+
return cls.error(400, message)
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def unauthorized(cls, message: str = "Unauthorized") -> PluginResponse:
|
|
506
|
+
"""Create a 401 Unauthorized response."""
|
|
507
|
+
return cls.error(401, message)
|
|
508
|
+
|
|
509
|
+
@classmethod
|
|
510
|
+
def forbidden(cls, message: str = "Forbidden") -> PluginResponse:
|
|
511
|
+
"""Create a 403 Forbidden response."""
|
|
512
|
+
return cls.error(403, message)
|
|
513
|
+
|
|
514
|
+
@classmethod
|
|
515
|
+
def internal_error(cls, message: str = "Internal Server Error") -> PluginResponse:
|
|
516
|
+
"""Create a 500 Internal Server Error response."""
|
|
517
|
+
return cls.error(500, message)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class HandleResult:
|
|
521
|
+
"""Result of handling a request.
|
|
522
|
+
|
|
523
|
+
This is a tagged union with three variants:
|
|
524
|
+
- response: Plugin handled the request, return the response
|
|
525
|
+
- pass_through: Plugin chose not to handle, continue to next handler
|
|
526
|
+
- error: Plugin encountered an error
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
__slots__ = ("_kind", "_value")
|
|
530
|
+
|
|
531
|
+
RESPONSE = 0
|
|
532
|
+
PASS_THROUGH = 1
|
|
533
|
+
ERROR = 2
|
|
534
|
+
|
|
535
|
+
def __init__(self, kind: int, value: Any = None) -> None:
|
|
536
|
+
self._kind = kind
|
|
537
|
+
self._value = value
|
|
538
|
+
|
|
539
|
+
@classmethod
|
|
540
|
+
def response(cls, resp: PluginResponse) -> HandleResult:
|
|
541
|
+
"""Create a response result."""
|
|
542
|
+
return cls(cls.RESPONSE, resp)
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def pass_through(cls) -> HandleResult:
|
|
546
|
+
"""Create a pass-through result."""
|
|
547
|
+
return cls(cls.PASS_THROUGH)
|
|
548
|
+
|
|
549
|
+
@classmethod
|
|
550
|
+
def error(cls, message: str) -> HandleResult:
|
|
551
|
+
"""Create an error result."""
|
|
552
|
+
return cls(cls.ERROR, message)
|
|
553
|
+
|
|
554
|
+
def is_response(self) -> bool:
|
|
555
|
+
"""Check if this is a response result."""
|
|
556
|
+
return self._kind == self.RESPONSE
|
|
557
|
+
|
|
558
|
+
def is_pass_through(self) -> bool:
|
|
559
|
+
"""Check if this is a pass-through result."""
|
|
560
|
+
return self._kind == self.PASS_THROUGH
|
|
561
|
+
|
|
562
|
+
def is_error(self) -> bool:
|
|
563
|
+
"""Check if this is an error result."""
|
|
564
|
+
return self._kind == self.ERROR
|
|
565
|
+
|
|
566
|
+
def get_response(self) -> PluginResponse:
|
|
567
|
+
"""Get the response (raises if not a response result)."""
|
|
568
|
+
if self._kind != self.RESPONSE:
|
|
569
|
+
raise ValueError("Not a response result")
|
|
570
|
+
return self._value
|
|
571
|
+
|
|
572
|
+
def get_error(self) -> str:
|
|
573
|
+
"""Get the error message (raises if not an error result)."""
|
|
574
|
+
if self._kind != self.ERROR:
|
|
575
|
+
raise ValueError("Not an error result")
|
|
576
|
+
return self._value
|
|
577
|
+
|
|
578
|
+
def __repr__(self) -> str:
|
|
579
|
+
if self._kind == self.RESPONSE:
|
|
580
|
+
return f"HandleResult.response({self._value!r})"
|
|
581
|
+
elif self._kind == self.PASS_THROUGH:
|
|
582
|
+
return "HandleResult.pass_through()"
|
|
583
|
+
else:
|
|
584
|
+
return f"HandleResult.error({self._value!r})"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class InitErrorKind(IntEnum):
|
|
588
|
+
"""Kind of initialization error."""
|
|
589
|
+
|
|
590
|
+
CONFIG_MISSING = 0
|
|
591
|
+
CONFIG_INVALID = 1
|
|
592
|
+
CAPABILITY_UNAVAILABLE = 2
|
|
593
|
+
FAILED = 3
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@dataclass(slots=True)
|
|
597
|
+
class InitError(Exception):
|
|
598
|
+
"""Plugin initialization error."""
|
|
599
|
+
|
|
600
|
+
kind: InitErrorKind
|
|
601
|
+
message: str
|
|
602
|
+
|
|
603
|
+
def __str__(self) -> str:
|
|
604
|
+
return f"{self.kind.name}: {self.message}"
|
|
605
|
+
|
|
606
|
+
@classmethod
|
|
607
|
+
def config_missing(cls, key: str) -> InitError:
|
|
608
|
+
"""Create a config missing error."""
|
|
609
|
+
return cls(InitErrorKind.CONFIG_MISSING, f"Required configuration missing: {key}")
|
|
610
|
+
|
|
611
|
+
@classmethod
|
|
612
|
+
def config_invalid(cls, key: str, reason: str) -> InitError:
|
|
613
|
+
"""Create a config invalid error."""
|
|
614
|
+
return cls(InitErrorKind.CONFIG_INVALID, f"Invalid configuration for {key}: {reason}")
|
|
615
|
+
|
|
616
|
+
@classmethod
|
|
617
|
+
def capability_unavailable(cls, capability: str) -> InitError:
|
|
618
|
+
"""Create a capability unavailable error."""
|
|
619
|
+
return cls(
|
|
620
|
+
InitErrorKind.CAPABILITY_UNAVAILABLE,
|
|
621
|
+
f"Required capability not available: {capability}",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
@classmethod
|
|
625
|
+
def failed(cls, reason: str) -> InitError:
|
|
626
|
+
"""Create a generic failure error."""
|
|
627
|
+
return cls(InitErrorKind.FAILED, reason)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# =============================================================================
|
|
631
|
+
# Logging
|
|
632
|
+
# =============================================================================
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class LogLevel(IntEnum):
|
|
636
|
+
"""Log severity levels matching WIT enum."""
|
|
637
|
+
|
|
638
|
+
TRACE = 0
|
|
639
|
+
DEBUG = 1
|
|
640
|
+
INFO = 2
|
|
641
|
+
WARN = 3
|
|
642
|
+
ERROR = 4
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# Placeholder for the actual binding - will be replaced by componentize-py generated code
|
|
646
|
+
_logging_binding: Any = None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _get_logging_binding() -> Any:
|
|
650
|
+
"""Get the logging binding, importing it lazily."""
|
|
651
|
+
global _logging_binding
|
|
652
|
+
if _logging_binding is None:
|
|
653
|
+
try:
|
|
654
|
+
# This import path is generated by componentize-py
|
|
655
|
+
from zlayer.bindings.zlayer.host import logging as binding
|
|
656
|
+
|
|
657
|
+
_logging_binding = binding
|
|
658
|
+
except ImportError:
|
|
659
|
+
# Not yet generated, use stub
|
|
660
|
+
_logging_binding = _LoggingStub()
|
|
661
|
+
return _logging_binding
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class _LoggingStub:
|
|
665
|
+
"""Stub for logging when bindings aren't available."""
|
|
666
|
+
|
|
667
|
+
def log(self, level: int, message: str) -> None:
|
|
668
|
+
level_name = LogLevel(level).name
|
|
669
|
+
print(f"[{level_name}] {message}")
|
|
670
|
+
|
|
671
|
+
def log_structured(self, level: int, message: str, fields: list[tuple[str, str]]) -> None:
|
|
672
|
+
level_name = LogLevel(level).name
|
|
673
|
+
fields_str = " ".join(f"{k}={v}" for k, v in fields)
|
|
674
|
+
print(f"[{level_name}] {message} {fields_str}")
|
|
675
|
+
|
|
676
|
+
def trace(self, message: str) -> None:
|
|
677
|
+
self.log(LogLevel.TRACE, message)
|
|
678
|
+
|
|
679
|
+
def debug(self, message: str) -> None:
|
|
680
|
+
self.log(LogLevel.DEBUG, message)
|
|
681
|
+
|
|
682
|
+
def info(self, message: str) -> None:
|
|
683
|
+
self.log(LogLevel.INFO, message)
|
|
684
|
+
|
|
685
|
+
def warn(self, message: str) -> None:
|
|
686
|
+
self.log(LogLevel.WARN, message)
|
|
687
|
+
|
|
688
|
+
def error(self, message: str) -> None:
|
|
689
|
+
self.log(LogLevel.ERROR, message)
|
|
690
|
+
|
|
691
|
+
def is_enabled(self, level: int) -> bool:
|
|
692
|
+
return True
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def log(level: LogLevel, msg: str) -> None:
|
|
696
|
+
"""Emit a log message at the specified level.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
level: The log level (TRACE, DEBUG, INFO, WARN, ERROR)
|
|
700
|
+
msg: The message to log
|
|
701
|
+
"""
|
|
702
|
+
_get_logging_binding().log(level, msg)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def log_trace(msg: str) -> None:
|
|
706
|
+
"""Emit a TRACE level log message."""
|
|
707
|
+
_get_logging_binding().trace(msg)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def log_debug(msg: str) -> None:
|
|
711
|
+
"""Emit a DEBUG level log message."""
|
|
712
|
+
_get_logging_binding().debug(msg)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def log_info(msg: str) -> None:
|
|
716
|
+
"""Emit an INFO level log message."""
|
|
717
|
+
_get_logging_binding().info(msg)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def log_warn(msg: str) -> None:
|
|
721
|
+
"""Emit a WARN level log message."""
|
|
722
|
+
_get_logging_binding().warn(msg)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def log_error(msg: str) -> None:
|
|
726
|
+
"""Emit an ERROR level log message."""
|
|
727
|
+
_get_logging_binding().error(msg)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def log_structured(level: LogLevel, msg: str, fields: dict[str, str]) -> None:
|
|
731
|
+
"""Emit a structured log with key-value fields.
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
level: The log level
|
|
735
|
+
msg: The message to log
|
|
736
|
+
fields: Additional structured fields to include
|
|
737
|
+
"""
|
|
738
|
+
field_tuples = [(k, v) for k, v in fields.items()]
|
|
739
|
+
_get_logging_binding().log_structured(level, msg, field_tuples)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def is_log_enabled(level: LogLevel) -> bool:
|
|
743
|
+
"""Check if a log level is enabled.
|
|
744
|
+
|
|
745
|
+
Use this before expensive log message construction.
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
level: The log level to check
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
True if the level is enabled
|
|
752
|
+
"""
|
|
753
|
+
return _get_logging_binding().is_enabled(level)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# =============================================================================
|
|
757
|
+
# Configuration
|
|
758
|
+
# =============================================================================
|
|
759
|
+
|
|
760
|
+
_config_binding: Any = None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _get_config_binding() -> Any:
|
|
764
|
+
"""Get the config binding, importing it lazily."""
|
|
765
|
+
global _config_binding
|
|
766
|
+
if _config_binding is None:
|
|
767
|
+
try:
|
|
768
|
+
from zlayer.bindings.zlayer.host import config as binding
|
|
769
|
+
|
|
770
|
+
_config_binding = binding
|
|
771
|
+
except ImportError:
|
|
772
|
+
_config_binding = _ConfigStub()
|
|
773
|
+
return _config_binding
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
class _ConfigStub:
|
|
777
|
+
"""Stub for config when bindings aren't available."""
|
|
778
|
+
|
|
779
|
+
_data: dict[str, str] = {}
|
|
780
|
+
|
|
781
|
+
def get(self, key: str) -> str | None:
|
|
782
|
+
return self._data.get(key)
|
|
783
|
+
|
|
784
|
+
def get_required(self, key: str) -> str:
|
|
785
|
+
if key not in self._data:
|
|
786
|
+
raise ConfigError(f"Required config key not found: {key}", key)
|
|
787
|
+
return self._data[key]
|
|
788
|
+
|
|
789
|
+
def get_many(self, keys: list[str]) -> list[tuple[str, str]]:
|
|
790
|
+
return [(k, v) for k in keys if (v := self._data.get(k)) is not None]
|
|
791
|
+
|
|
792
|
+
def get_prefix(self, prefix: str) -> list[tuple[str, str]]:
|
|
793
|
+
return [(k, v) for k, v in self._data.items() if k.startswith(prefix)]
|
|
794
|
+
|
|
795
|
+
def exists(self, key: str) -> bool:
|
|
796
|
+
return key in self._data
|
|
797
|
+
|
|
798
|
+
def get_bool(self, key: str) -> bool | None:
|
|
799
|
+
val = self._data.get(key)
|
|
800
|
+
if val is None:
|
|
801
|
+
return None
|
|
802
|
+
return val.lower() in ("true", "1", "yes")
|
|
803
|
+
|
|
804
|
+
def get_int(self, key: str) -> int | None:
|
|
805
|
+
val = self._data.get(key)
|
|
806
|
+
if val is None:
|
|
807
|
+
return None
|
|
808
|
+
return int(val)
|
|
809
|
+
|
|
810
|
+
def get_float(self, key: str) -> float | None:
|
|
811
|
+
val = self._data.get(key)
|
|
812
|
+
if val is None:
|
|
813
|
+
return None
|
|
814
|
+
return float(val)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def get_config(key: str) -> str | None:
|
|
818
|
+
"""Get a configuration value by key.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
key: The configuration key
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
The configuration value, or None if not found
|
|
825
|
+
"""
|
|
826
|
+
return _get_config_binding().get(key)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def get_config_required(key: str) -> str:
|
|
830
|
+
"""Get a required configuration value by key.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
key: The configuration key
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
The configuration value
|
|
837
|
+
|
|
838
|
+
Raises:
|
|
839
|
+
ConfigError: If the key is not found
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
return _get_config_binding().get_required(key)
|
|
843
|
+
except Exception as e:
|
|
844
|
+
raise ConfigError(str(e), key) from e
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def get_config_bool(key: str) -> bool | None:
|
|
848
|
+
"""Get a configuration value as a boolean.
|
|
849
|
+
|
|
850
|
+
Recognizes: "true", "false", "1", "0", "yes", "no"
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
key: The configuration key
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
The boolean value, or None if not found
|
|
857
|
+
"""
|
|
858
|
+
return _get_config_binding().get_bool(key)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def get_config_int(key: str) -> int | None:
|
|
862
|
+
"""Get a configuration value as an integer.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
key: The configuration key
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
The integer value, or None if not found
|
|
869
|
+
"""
|
|
870
|
+
return _get_config_binding().get_int(key)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def get_config_float(key: str) -> float | None:
|
|
874
|
+
"""Get a configuration value as a float.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
key: The configuration key
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
The float value, or None if not found
|
|
881
|
+
"""
|
|
882
|
+
return _get_config_binding().get_float(key)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def get_config_many(keys: list[str]) -> dict[str, str]:
|
|
886
|
+
"""Get multiple configuration values at once.
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
keys: List of configuration keys to retrieve
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
Dictionary of key-value pairs for keys that exist
|
|
893
|
+
"""
|
|
894
|
+
result = _get_config_binding().get_many(keys)
|
|
895
|
+
return dict(result)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def get_config_prefix(prefix: str) -> dict[str, str]:
|
|
899
|
+
"""Get all configuration keys with a given prefix.
|
|
900
|
+
|
|
901
|
+
Example:
|
|
902
|
+
get_config_prefix("database.") returns all database.* keys
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
prefix: The prefix to match
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
Dictionary of matching key-value pairs
|
|
909
|
+
"""
|
|
910
|
+
result = _get_config_binding().get_prefix(prefix)
|
|
911
|
+
return dict(result)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def config_exists(key: str) -> bool:
|
|
915
|
+
"""Check if a configuration key exists.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
key: The configuration key
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
True if the key exists
|
|
922
|
+
"""
|
|
923
|
+
return _get_config_binding().exists(key)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# =============================================================================
|
|
927
|
+
# Key-Value Storage
|
|
928
|
+
# =============================================================================
|
|
929
|
+
|
|
930
|
+
_kv_binding: Any = None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _get_kv_binding() -> Any:
|
|
934
|
+
"""Get the keyvalue binding, importing it lazily."""
|
|
935
|
+
global _kv_binding
|
|
936
|
+
if _kv_binding is None:
|
|
937
|
+
try:
|
|
938
|
+
from zlayer.bindings.zlayer.host import keyvalue as binding
|
|
939
|
+
|
|
940
|
+
_kv_binding = binding
|
|
941
|
+
except ImportError:
|
|
942
|
+
_kv_binding = _KVStub()
|
|
943
|
+
return _kv_binding
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class _KVStub:
|
|
947
|
+
"""Stub for keyvalue when bindings aren't available."""
|
|
948
|
+
|
|
949
|
+
_data: dict[str, bytes] = {}
|
|
950
|
+
|
|
951
|
+
def get(self, key: str) -> bytes | None:
|
|
952
|
+
return self._data.get(key)
|
|
953
|
+
|
|
954
|
+
def get_string(self, key: str) -> str | None:
|
|
955
|
+
val = self._data.get(key)
|
|
956
|
+
return val.decode("utf-8") if val else None
|
|
957
|
+
|
|
958
|
+
def set(self, key: str, value: bytes) -> None:
|
|
959
|
+
self._data[key] = value
|
|
960
|
+
|
|
961
|
+
def set_string(self, key: str, value: str) -> None:
|
|
962
|
+
self._data[key] = value.encode("utf-8")
|
|
963
|
+
|
|
964
|
+
def set_with_ttl(self, key: str, value: bytes, ttl_ns: int) -> None:
|
|
965
|
+
self._data[key] = value
|
|
966
|
+
|
|
967
|
+
def delete(self, key: str) -> bool:
|
|
968
|
+
if key in self._data:
|
|
969
|
+
del self._data[key]
|
|
970
|
+
return True
|
|
971
|
+
return False
|
|
972
|
+
|
|
973
|
+
def exists(self, key: str) -> bool:
|
|
974
|
+
return key in self._data
|
|
975
|
+
|
|
976
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
977
|
+
return [k for k in self._data.keys() if k.startswith(prefix)]
|
|
978
|
+
|
|
979
|
+
def increment(self, key: str, delta: int) -> int:
|
|
980
|
+
current = self._data.get(key, b"0")
|
|
981
|
+
new_val = int(current.decode("utf-8")) + delta
|
|
982
|
+
self._data[key] = str(new_val).encode("utf-8")
|
|
983
|
+
return new_val
|
|
984
|
+
|
|
985
|
+
def compare_and_swap(
|
|
986
|
+
self, key: str, expected: bytes | None, new_value: bytes
|
|
987
|
+
) -> bool:
|
|
988
|
+
current = self._data.get(key)
|
|
989
|
+
if current == expected:
|
|
990
|
+
self._data[key] = new_value
|
|
991
|
+
return True
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def kv_get(key: str) -> bytes | None:
|
|
996
|
+
"""Get a value by key.
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
key: The key to retrieve
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
The value as bytes, or None if not found
|
|
1003
|
+
|
|
1004
|
+
Raises:
|
|
1005
|
+
KVError: On storage errors
|
|
1006
|
+
"""
|
|
1007
|
+
try:
|
|
1008
|
+
return _get_kv_binding().get(key)
|
|
1009
|
+
except Exception as e:
|
|
1010
|
+
raise KVError(str(e), key=key) from e
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def kv_get_str(key: str) -> str | None:
|
|
1014
|
+
"""Get a value as a string.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
key: The key to retrieve
|
|
1018
|
+
|
|
1019
|
+
Returns:
|
|
1020
|
+
The value as a string, or None if not found
|
|
1021
|
+
|
|
1022
|
+
Raises:
|
|
1023
|
+
KVError: On storage errors
|
|
1024
|
+
"""
|
|
1025
|
+
try:
|
|
1026
|
+
return _get_kv_binding().get_string(key)
|
|
1027
|
+
except Exception as e:
|
|
1028
|
+
raise KVError(str(e), key=key) from e
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def kv_set(key: str, value: bytes) -> None:
|
|
1032
|
+
"""Set a value.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
key: The key to set
|
|
1036
|
+
value: The value as bytes
|
|
1037
|
+
|
|
1038
|
+
Raises:
|
|
1039
|
+
KVError: On storage errors
|
|
1040
|
+
"""
|
|
1041
|
+
try:
|
|
1042
|
+
_get_kv_binding().set(key, value)
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
raise KVError(str(e), key=key) from e
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def kv_set_str(key: str, value: str) -> None:
|
|
1048
|
+
"""Set a string value.
|
|
1049
|
+
|
|
1050
|
+
Args:
|
|
1051
|
+
key: The key to set
|
|
1052
|
+
value: The value as a string
|
|
1053
|
+
|
|
1054
|
+
Raises:
|
|
1055
|
+
KVError: On storage errors
|
|
1056
|
+
"""
|
|
1057
|
+
try:
|
|
1058
|
+
_get_kv_binding().set_string(key, value)
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
raise KVError(str(e), key=key) from e
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def kv_set_with_ttl(key: str, value: bytes, ttl_ns: int) -> None:
|
|
1064
|
+
"""Set a value with a TTL (time-to-live).
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
key: The key to set
|
|
1068
|
+
value: The value as bytes
|
|
1069
|
+
ttl_ns: Time-to-live in nanoseconds
|
|
1070
|
+
|
|
1071
|
+
Raises:
|
|
1072
|
+
KVError: On storage errors
|
|
1073
|
+
"""
|
|
1074
|
+
try:
|
|
1075
|
+
_get_kv_binding().set_with_ttl(key, value, ttl_ns)
|
|
1076
|
+
except Exception as e:
|
|
1077
|
+
raise KVError(str(e), key=key) from e
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def kv_delete(key: str) -> bool:
|
|
1081
|
+
"""Delete a key.
|
|
1082
|
+
|
|
1083
|
+
Args:
|
|
1084
|
+
key: The key to delete
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
True if the key was deleted, False if it didn't exist
|
|
1088
|
+
|
|
1089
|
+
Raises:
|
|
1090
|
+
KVError: On storage errors
|
|
1091
|
+
"""
|
|
1092
|
+
try:
|
|
1093
|
+
return _get_kv_binding().delete(key)
|
|
1094
|
+
except Exception as e:
|
|
1095
|
+
raise KVError(str(e), key=key) from e
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def kv_keys(prefix: str) -> list[str]:
|
|
1099
|
+
"""List all keys with a given prefix.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
prefix: The prefix to match
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
List of matching keys
|
|
1106
|
+
|
|
1107
|
+
Raises:
|
|
1108
|
+
KVError: On storage errors
|
|
1109
|
+
"""
|
|
1110
|
+
try:
|
|
1111
|
+
return _get_kv_binding().list_keys(prefix)
|
|
1112
|
+
except Exception as e:
|
|
1113
|
+
raise KVError(str(e)) from e
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def kv_exists(key: str) -> bool:
|
|
1117
|
+
"""Check if a key exists.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
key: The key to check
|
|
1121
|
+
|
|
1122
|
+
Returns:
|
|
1123
|
+
True if the key exists
|
|
1124
|
+
"""
|
|
1125
|
+
return _get_kv_binding().exists(key)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def kv_increment(key: str, delta: int = 1) -> int:
|
|
1129
|
+
"""Increment a numeric value atomically.
|
|
1130
|
+
|
|
1131
|
+
Args:
|
|
1132
|
+
key: The key to increment
|
|
1133
|
+
delta: The amount to add (can be negative)
|
|
1134
|
+
|
|
1135
|
+
Returns:
|
|
1136
|
+
The new value after increment
|
|
1137
|
+
|
|
1138
|
+
Raises:
|
|
1139
|
+
KVError: On storage errors
|
|
1140
|
+
"""
|
|
1141
|
+
try:
|
|
1142
|
+
return _get_kv_binding().increment(key, delta)
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
raise KVError(str(e), key=key) from e
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def kv_compare_and_swap(key: str, expected: bytes | None, new_value: bytes) -> bool:
|
|
1148
|
+
"""Compare and swap - set value only if current value matches expected.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
key: The key to set
|
|
1152
|
+
expected: The expected current value (None for key not existing)
|
|
1153
|
+
new_value: The new value to set
|
|
1154
|
+
|
|
1155
|
+
Returns:
|
|
1156
|
+
True if swap succeeded, False if current value didn't match
|
|
1157
|
+
|
|
1158
|
+
Raises:
|
|
1159
|
+
KVError: On storage errors
|
|
1160
|
+
"""
|
|
1161
|
+
try:
|
|
1162
|
+
return _get_kv_binding().compare_and_swap(key, expected, new_value)
|
|
1163
|
+
except Exception as e:
|
|
1164
|
+
raise KVError(str(e), key=key) from e
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
# =============================================================================
|
|
1168
|
+
# Secrets
|
|
1169
|
+
# =============================================================================
|
|
1170
|
+
|
|
1171
|
+
_secrets_binding: Any = None
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _get_secrets_binding() -> Any:
|
|
1175
|
+
"""Get the secrets binding, importing it lazily."""
|
|
1176
|
+
global _secrets_binding
|
|
1177
|
+
if _secrets_binding is None:
|
|
1178
|
+
try:
|
|
1179
|
+
from zlayer.bindings.zlayer.host import secrets as binding
|
|
1180
|
+
|
|
1181
|
+
_secrets_binding = binding
|
|
1182
|
+
except ImportError:
|
|
1183
|
+
_secrets_binding = _SecretsStub()
|
|
1184
|
+
return _secrets_binding
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
class _SecretsStub:
|
|
1188
|
+
"""Stub for secrets when bindings aren't available."""
|
|
1189
|
+
|
|
1190
|
+
_data: dict[str, str] = {}
|
|
1191
|
+
|
|
1192
|
+
def get(self, name: str) -> str | None:
|
|
1193
|
+
return self._data.get(name)
|
|
1194
|
+
|
|
1195
|
+
def get_required(self, name: str) -> str:
|
|
1196
|
+
if name not in self._data:
|
|
1197
|
+
raise SecretError(f"Required secret not found: {name}", name)
|
|
1198
|
+
return self._data[name]
|
|
1199
|
+
|
|
1200
|
+
def exists(self, name: str) -> bool:
|
|
1201
|
+
return name in self._data
|
|
1202
|
+
|
|
1203
|
+
def list_names(self) -> list[str]:
|
|
1204
|
+
return list(self._data.keys())
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def get_secret(name: str) -> str | None:
|
|
1208
|
+
"""Get a secret by name.
|
|
1209
|
+
|
|
1210
|
+
Secrets are write-once by the deployment, read-only by plugins.
|
|
1211
|
+
|
|
1212
|
+
Args:
|
|
1213
|
+
name: The secret name
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
The secret value, or None if not found
|
|
1217
|
+
|
|
1218
|
+
Raises:
|
|
1219
|
+
SecretError: On access errors
|
|
1220
|
+
"""
|
|
1221
|
+
try:
|
|
1222
|
+
return _get_secrets_binding().get(name)
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
raise SecretError(str(e), name) from e
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def get_secret_required(name: str) -> str:
|
|
1228
|
+
"""Get a required secret, error if not found.
|
|
1229
|
+
|
|
1230
|
+
Args:
|
|
1231
|
+
name: The secret name
|
|
1232
|
+
|
|
1233
|
+
Returns:
|
|
1234
|
+
The secret value
|
|
1235
|
+
|
|
1236
|
+
Raises:
|
|
1237
|
+
SecretError: If the secret is not found or on access errors
|
|
1238
|
+
"""
|
|
1239
|
+
try:
|
|
1240
|
+
return _get_secrets_binding().get_required(name)
|
|
1241
|
+
except Exception as e:
|
|
1242
|
+
raise SecretError(str(e), name) from e
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def secret_exists(name: str) -> bool:
|
|
1246
|
+
"""Check if a secret exists.
|
|
1247
|
+
|
|
1248
|
+
Args:
|
|
1249
|
+
name: The secret name
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
True if the secret exists
|
|
1253
|
+
"""
|
|
1254
|
+
return _get_secrets_binding().exists(name)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def list_secret_names() -> list[str]:
|
|
1258
|
+
"""List available secret names (not values).
|
|
1259
|
+
|
|
1260
|
+
Useful for diagnostics without exposing sensitive data.
|
|
1261
|
+
|
|
1262
|
+
Returns:
|
|
1263
|
+
List of secret names
|
|
1264
|
+
"""
|
|
1265
|
+
return _get_secrets_binding().list_names()
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
# =============================================================================
|
|
1269
|
+
# Metrics
|
|
1270
|
+
# =============================================================================
|
|
1271
|
+
|
|
1272
|
+
_metrics_binding: Any = None
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _get_metrics_binding() -> Any:
|
|
1276
|
+
"""Get the metrics binding, importing it lazily."""
|
|
1277
|
+
global _metrics_binding
|
|
1278
|
+
if _metrics_binding is None:
|
|
1279
|
+
try:
|
|
1280
|
+
from zlayer.bindings.zlayer.host import metrics as binding
|
|
1281
|
+
|
|
1282
|
+
_metrics_binding = binding
|
|
1283
|
+
except ImportError:
|
|
1284
|
+
_metrics_binding = _MetricsStub()
|
|
1285
|
+
return _metrics_binding
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
class _MetricsStub:
|
|
1289
|
+
"""Stub for metrics when bindings aren't available."""
|
|
1290
|
+
|
|
1291
|
+
def counter_inc(self, name: str, value: int) -> None:
|
|
1292
|
+
pass
|
|
1293
|
+
|
|
1294
|
+
def counter_inc_labeled(
|
|
1295
|
+
self, name: str, value: int, labels: list[tuple[str, str]]
|
|
1296
|
+
) -> None:
|
|
1297
|
+
pass
|
|
1298
|
+
|
|
1299
|
+
def gauge_set(self, name: str, value: float) -> None:
|
|
1300
|
+
pass
|
|
1301
|
+
|
|
1302
|
+
def gauge_set_labeled(
|
|
1303
|
+
self, name: str, value: float, labels: list[tuple[str, str]]
|
|
1304
|
+
) -> None:
|
|
1305
|
+
pass
|
|
1306
|
+
|
|
1307
|
+
def gauge_add(self, name: str, delta: float) -> None:
|
|
1308
|
+
pass
|
|
1309
|
+
|
|
1310
|
+
def histogram_observe(self, name: str, value: float) -> None:
|
|
1311
|
+
pass
|
|
1312
|
+
|
|
1313
|
+
def histogram_observe_labeled(
|
|
1314
|
+
self, name: str, value: float, labels: list[tuple[str, str]]
|
|
1315
|
+
) -> None:
|
|
1316
|
+
pass
|
|
1317
|
+
|
|
1318
|
+
def record_duration(self, name: str, duration_ns: int) -> None:
|
|
1319
|
+
pass
|
|
1320
|
+
|
|
1321
|
+
def record_duration_labeled(
|
|
1322
|
+
self, name: str, duration_ns: int, labels: list[tuple[str, str]]
|
|
1323
|
+
) -> None:
|
|
1324
|
+
pass
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def counter_inc(name: str, value: int = 1) -> None:
|
|
1328
|
+
"""Increment a counter metric.
|
|
1329
|
+
|
|
1330
|
+
Args:
|
|
1331
|
+
name: The metric name
|
|
1332
|
+
value: The value to add (default 1)
|
|
1333
|
+
"""
|
|
1334
|
+
_get_metrics_binding().counter_inc(name, value)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def counter_inc_labeled(name: str, value: int, labels: dict[str, str]) -> None:
|
|
1338
|
+
"""Increment a counter metric with labels.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
name: The metric name
|
|
1342
|
+
value: The value to add
|
|
1343
|
+
labels: Additional labels for the metric
|
|
1344
|
+
"""
|
|
1345
|
+
label_tuples = [(k, v) for k, v in labels.items()]
|
|
1346
|
+
_get_metrics_binding().counter_inc_labeled(name, value, label_tuples)
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def gauge_set(name: str, value: float) -> None:
|
|
1350
|
+
"""Set a gauge metric to a value.
|
|
1351
|
+
|
|
1352
|
+
Args:
|
|
1353
|
+
name: The metric name
|
|
1354
|
+
value: The value to set
|
|
1355
|
+
"""
|
|
1356
|
+
_get_metrics_binding().gauge_set(name, value)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def gauge_set_labeled(name: str, value: float, labels: dict[str, str]) -> None:
|
|
1360
|
+
"""Set a gauge metric with labels.
|
|
1361
|
+
|
|
1362
|
+
Args:
|
|
1363
|
+
name: The metric name
|
|
1364
|
+
value: The value to set
|
|
1365
|
+
labels: Additional labels for the metric
|
|
1366
|
+
"""
|
|
1367
|
+
label_tuples = [(k, v) for k, v in labels.items()]
|
|
1368
|
+
_get_metrics_binding().gauge_set_labeled(name, value, label_tuples)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def gauge_add(name: str, delta: float) -> None:
|
|
1372
|
+
"""Add to a gauge metric value (can be negative).
|
|
1373
|
+
|
|
1374
|
+
Args:
|
|
1375
|
+
name: The metric name
|
|
1376
|
+
delta: The value to add (can be negative)
|
|
1377
|
+
"""
|
|
1378
|
+
_get_metrics_binding().gauge_add(name, delta)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def histogram_observe(name: str, value: float) -> None:
|
|
1382
|
+
"""Record a histogram observation.
|
|
1383
|
+
|
|
1384
|
+
Args:
|
|
1385
|
+
name: The metric name
|
|
1386
|
+
value: The observed value
|
|
1387
|
+
"""
|
|
1388
|
+
_get_metrics_binding().histogram_observe(name, value)
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def histogram_observe_labeled(name: str, value: float, labels: dict[str, str]) -> None:
|
|
1392
|
+
"""Record a histogram observation with labels.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
name: The metric name
|
|
1396
|
+
value: The observed value
|
|
1397
|
+
labels: Additional labels for the metric
|
|
1398
|
+
"""
|
|
1399
|
+
label_tuples = [(k, v) for k, v in labels.items()]
|
|
1400
|
+
_get_metrics_binding().histogram_observe_labeled(name, value, label_tuples)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def record_duration(name: str, duration_ns: int) -> None:
|
|
1404
|
+
"""Record request duration in nanoseconds.
|
|
1405
|
+
|
|
1406
|
+
Convenience method that records to a standard histogram.
|
|
1407
|
+
|
|
1408
|
+
Args:
|
|
1409
|
+
name: The metric name
|
|
1410
|
+
duration_ns: Duration in nanoseconds
|
|
1411
|
+
"""
|
|
1412
|
+
_get_metrics_binding().record_duration(name, duration_ns)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def record_duration_labeled(
|
|
1416
|
+
name: str, duration_ns: int, labels: dict[str, str]
|
|
1417
|
+
) -> None:
|
|
1418
|
+
"""Record request duration with labels.
|
|
1419
|
+
|
|
1420
|
+
Args:
|
|
1421
|
+
name: The metric name
|
|
1422
|
+
duration_ns: Duration in nanoseconds
|
|
1423
|
+
labels: Additional labels for the metric
|
|
1424
|
+
"""
|
|
1425
|
+
label_tuples = [(k, v) for k, v in labels.items()]
|
|
1426
|
+
_get_metrics_binding().record_duration_labeled(name, duration_ns, label_tuples)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
# =============================================================================
|
|
1430
|
+
# Plugin Base Class
|
|
1431
|
+
# =============================================================================
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def require_capability(capability: int) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
1435
|
+
"""Decorator to mark a method as requiring a specific capability.
|
|
1436
|
+
|
|
1437
|
+
This is a documentation/validation decorator that can be used to
|
|
1438
|
+
clearly indicate which capabilities a method needs.
|
|
1439
|
+
|
|
1440
|
+
Args:
|
|
1441
|
+
capability: The capability flag (e.g., Capabilities.KEYVALUE)
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
The decorated function
|
|
1445
|
+
|
|
1446
|
+
Example:
|
|
1447
|
+
@require_capability(Capabilities.KEYVALUE)
|
|
1448
|
+
def store_data(self, key: str, value: bytes) -> None:
|
|
1449
|
+
kv_set(key, value)
|
|
1450
|
+
"""
|
|
1451
|
+
|
|
1452
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
1453
|
+
func._required_capability = capability # type: ignore
|
|
1454
|
+
return func
|
|
1455
|
+
|
|
1456
|
+
return decorator
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
class ZLayerPlugin(ABC):
|
|
1460
|
+
"""Base class for ZLayer plugins.
|
|
1461
|
+
|
|
1462
|
+
Subclass this to create a plugin. Override the abstract methods
|
|
1463
|
+
to define your plugin's behavior.
|
|
1464
|
+
|
|
1465
|
+
Example:
|
|
1466
|
+
class MyPlugin(ZLayerPlugin):
|
|
1467
|
+
def init(self) -> Capabilities:
|
|
1468
|
+
log_info("MyPlugin initializing")
|
|
1469
|
+
return Capabilities.LOGGING | Capabilities.CONFIG
|
|
1470
|
+
|
|
1471
|
+
def info(self) -> PluginInfo:
|
|
1472
|
+
return PluginInfo(
|
|
1473
|
+
id="mycompany:my-plugin",
|
|
1474
|
+
name="My Plugin",
|
|
1475
|
+
version="1.0.0",
|
|
1476
|
+
description="A sample plugin",
|
|
1477
|
+
author="My Company",
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
def handle(self, request: PluginRequest) -> HandleResult:
|
|
1481
|
+
if request.path == "/health":
|
|
1482
|
+
return HandleResult.response(PluginResponse.ok("healthy"))
|
|
1483
|
+
return HandleResult.pass_through()
|
|
1484
|
+
|
|
1485
|
+
def shutdown(self) -> None:
|
|
1486
|
+
log_info("MyPlugin shutting down")
|
|
1487
|
+
"""
|
|
1488
|
+
|
|
1489
|
+
def init(self) -> Capabilities:
|
|
1490
|
+
"""Initialize the plugin.
|
|
1491
|
+
|
|
1492
|
+
Called once when the plugin is loaded. The plugin should:
|
|
1493
|
+
1. Read any required configuration
|
|
1494
|
+
2. Initialize internal state
|
|
1495
|
+
3. Return the capabilities it needs
|
|
1496
|
+
|
|
1497
|
+
Returns:
|
|
1498
|
+
The capabilities required by the plugin
|
|
1499
|
+
|
|
1500
|
+
Raises:
|
|
1501
|
+
InitError: If initialization fails
|
|
1502
|
+
"""
|
|
1503
|
+
return Capabilities.LOGGING
|
|
1504
|
+
|
|
1505
|
+
@abstractmethod
|
|
1506
|
+
def info(self) -> PluginInfo:
|
|
1507
|
+
"""Return plugin metadata.
|
|
1508
|
+
|
|
1509
|
+
Called after successful initialization. This information
|
|
1510
|
+
is used for logging, metrics, and management.
|
|
1511
|
+
|
|
1512
|
+
Returns:
|
|
1513
|
+
Plugin information
|
|
1514
|
+
"""
|
|
1515
|
+
...
|
|
1516
|
+
|
|
1517
|
+
@abstractmethod
|
|
1518
|
+
def handle(self, request: PluginRequest) -> HandleResult:
|
|
1519
|
+
"""Handle an incoming request.
|
|
1520
|
+
|
|
1521
|
+
Called for each request that matches the plugin's routing rules.
|
|
1522
|
+
The plugin should:
|
|
1523
|
+
1. Examine the request
|
|
1524
|
+
2. Process it or decide to pass through
|
|
1525
|
+
3. Return an appropriate result
|
|
1526
|
+
|
|
1527
|
+
Args:
|
|
1528
|
+
request: The incoming request
|
|
1529
|
+
|
|
1530
|
+
Returns:
|
|
1531
|
+
The result of handling:
|
|
1532
|
+
- HandleResult.response(): Plugin handled the request
|
|
1533
|
+
- HandleResult.pass_through(): Continue to next handler
|
|
1534
|
+
- HandleResult.error(): Plugin encountered an error
|
|
1535
|
+
"""
|
|
1536
|
+
...
|
|
1537
|
+
|
|
1538
|
+
def shutdown(self) -> None:
|
|
1539
|
+
"""Graceful shutdown hook.
|
|
1540
|
+
|
|
1541
|
+
Called when the plugin is being unloaded. Plugins should
|
|
1542
|
+
clean up any resources. This is a best-effort call - plugins
|
|
1543
|
+
may be terminated without shutdown if they don't respond in time.
|
|
1544
|
+
"""
|
|
1545
|
+
pass
|
|
1546
|
+
|
|
1547
|
+
def get_required_capabilities(self) -> Capabilities:
|
|
1548
|
+
"""Get capabilities required by this plugin.
|
|
1549
|
+
|
|
1550
|
+
Scans methods decorated with @require_capability to determine
|
|
1551
|
+
what capabilities the plugin needs.
|
|
1552
|
+
|
|
1553
|
+
Returns:
|
|
1554
|
+
Combined capabilities from all decorated methods
|
|
1555
|
+
"""
|
|
1556
|
+
caps = Capabilities.none()
|
|
1557
|
+
for name in dir(self):
|
|
1558
|
+
method = getattr(self, name, None)
|
|
1559
|
+
if callable(method):
|
|
1560
|
+
cap = getattr(method, "_required_capability", None)
|
|
1561
|
+
if cap is not None:
|
|
1562
|
+
caps = caps | cap
|
|
1563
|
+
return caps
|