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 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