temporal-parseable 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.
@@ -0,0 +1,162 @@
1
+ """
2
+ temporal-parseable
3
+ ==================
4
+
5
+ Temporal plugin that ships workflow and activity execution events to Parseable
6
+ as OpenTelemetry structured logs and traces.
7
+
8
+ Quick start — same pattern as any Temporal plugin::
9
+
10
+ from temporalio.client import Client
11
+ from temporalio.worker import Worker
12
+ from temporal_parseable import ParseablePlugin, ParseableConfig
13
+
14
+ config = ParseableConfig(
15
+ service_name="my-worker",
16
+ endpoint="https://parseable.example.com",
17
+ username="admin",
18
+ password="secret",
19
+ )
20
+ plugin = ParseablePlugin(config)
21
+
22
+ # Add to client for span context propagation (links client → workflow traces)
23
+ client = await Client.connect("localhost:7233", plugins=[plugin])
24
+
25
+ # Add to worker for activity + workflow interception
26
+ # No SandboxedWorkflowRunner needed — the plugin handles it automatically
27
+ async with Worker(
28
+ client,
29
+ task_queue="my-queue",
30
+ workflows=[MyWorkflow],
31
+ activities=[my_activity],
32
+ plugins=[plugin],
33
+ ):
34
+ await asyncio.Event().wait()
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Optional, Type
40
+
41
+ from temporalio.plugin import SimplePlugin
42
+ from temporalio.worker import Interceptor, WorkflowInterceptorClassInput
43
+ from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
44
+
45
+ from .config import ParseableConfig, LogsConfig, TracesConfig
46
+ from .exporters import build_tracer_provider, build_logger_provider
47
+ from ._emitter import ParseableEmitter
48
+ from .activity_interceptor import ParseableActivityInterceptor
49
+ from .workflow_interceptor import (
50
+ ParseableWorkflowInboundInterceptor,
51
+ ParseableWorkflowOutboundInterceptor,
52
+ )
53
+ from . import workflow as _workflow_module
54
+ from ._version import PLUGIN_VERSION
55
+
56
+ __version__ = PLUGIN_VERSION
57
+ __all__ = [
58
+ "ParseablePlugin",
59
+ "ParseableConfig",
60
+ "LogsConfig",
61
+ "TracesConfig",
62
+ "PLUGIN_VERSION",
63
+ ]
64
+
65
+
66
+ def _build_sandbox() -> SandboxedWorkflowRunner:
67
+ """
68
+ Build a SandboxedWorkflowRunner with temporal_parseable marked as passthrough.
69
+
70
+ Without this, the Temporal workflow sandbox tries to import OTel/requests
71
+ inside the isolate and raises RestrictedWorkflowAccessError. Injecting the
72
+ runner via SimplePlugin means users never need to configure this themselves.
73
+ """
74
+ return SandboxedWorkflowRunner(
75
+ restrictions=SandboxRestrictions.default.with_passthrough_modules(
76
+ "temporal_parseable"
77
+ )
78
+ )
79
+
80
+
81
+ class ParseablePlugin(SimplePlugin):
82
+ """
83
+ Temporal plugin that ships workflow and activity events to Parseable.
84
+
85
+ Pass a single instance to both Client.connect and Worker — the plugin
86
+ is safe to reuse across both::
87
+
88
+ plugin = ParseablePlugin(ParseableConfig())
89
+
90
+ client = await Client.connect("localhost:7233", plugins=[plugin])
91
+
92
+ async with Worker(client, task_queue="q", workflows=[W], plugins=[plugin]):
93
+ ...
94
+
95
+ The plugin automatically:
96
+ - Configures the workflow sandbox passthrough (no SandboxedWorkflowRunner needed)
97
+ - Wires activity and workflow interceptors
98
+ - Sets up OTel log and trace pipelines to Parseable
99
+ """
100
+
101
+ def __init__(self, config: Optional[ParseableConfig] = None) -> None:
102
+ self._config = config or ParseableConfig()
103
+
104
+ # Build OTel providers
105
+ self._tracer_provider = build_tracer_provider(self._config)
106
+ self._logger_provider = build_logger_provider(self._config)
107
+
108
+ # Shared emitter used by all interceptors
109
+ self._emitter = ParseableEmitter(
110
+ logger_provider=self._logger_provider,
111
+ service_name=self._config.service_name,
112
+ )
113
+
114
+ # Make the emitter available to workflow_event()
115
+ _workflow_module._set_emitter(self._emitter)
116
+
117
+ worker_interceptor = _ParseableWorkerInterceptor(self._emitter)
118
+
119
+ super().__init__(
120
+ name="parseable.temporal",
121
+ interceptors=[worker_interceptor],
122
+ # Inject sandbox passthrough automatically — users don't need to
123
+ # configure SandboxedWorkflowRunner manually.
124
+ workflow_runner=_build_sandbox(),
125
+ )
126
+
127
+ @property
128
+ def config(self) -> ParseableConfig:
129
+ return self._config
130
+
131
+ def shutdown(self) -> None:
132
+ """Flush and shut down OTel providers. Call on clean worker exit."""
133
+ if self._tracer_provider:
134
+ self._tracer_provider.shutdown()
135
+ if self._logger_provider:
136
+ self._logger_provider.shutdown()
137
+
138
+
139
+ class _ParseableWorkerInterceptor(Interceptor):
140
+ """
141
+ Worker-level interceptor that wires activity and workflow interceptors.
142
+ One instance lives on the worker per plugin instance.
143
+ """
144
+
145
+ def __init__(self, emitter: ParseableEmitter) -> None:
146
+ self._emitter = emitter
147
+
148
+ def intercept_activity(self, next): # type: ignore[override]
149
+ return ParseableActivityInterceptor(next, self._emitter)
150
+
151
+ def workflow_interceptor_class(
152
+ self, input: WorkflowInterceptorClassInput
153
+ ) -> Type[ParseableWorkflowInboundInterceptor]:
154
+ emitter = self._emitter
155
+
156
+ class _Injected(ParseableWorkflowInboundInterceptor):
157
+ # emitter is set in init() AFTER _outbound is created, not in __init__
158
+ def init(self, outbound): # type: ignore[override]
159
+ super().init(outbound)
160
+ self._set_emitter(emitter)
161
+
162
+ return _Injected
@@ -0,0 +1,83 @@
1
+ """
2
+ Internal log record emitter.
3
+
4
+ All interceptors share a single ParseableEmitter instance (held on the
5
+ ParseablePlugin) which serialises ParseableEventRecord dicts and forwards
6
+ them to the OTel LoggerProvider.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Optional
15
+
16
+ from opentelemetry.sdk._logs import LoggerProvider
17
+ from opentelemetry._logs.severity import SeverityNumber
18
+
19
+ from .types import ParseableEventRecord
20
+ from ._version import PLUGIN_VERSION
21
+
22
+
23
+ class ParseableEmitter:
24
+ """
25
+ Thread-safe (asyncio-safe) emitter for structured Parseable log records.
26
+
27
+ Usage::
28
+
29
+ emitter = ParseableEmitter(logger_provider, service_name="my-worker")
30
+ emitter.emit({"type": "workflow", "status": "started", ...})
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ logger_provider: Optional[LoggerProvider],
36
+ service_name: str,
37
+ ) -> None:
38
+ self._service_name = service_name
39
+ self._plugin_version = PLUGIN_VERSION
40
+ if logger_provider is not None:
41
+ self._logger = logger_provider.get_logger(
42
+ "temporal_parseable",
43
+ schema_url="https://parseable.com/temporal/schema/v1",
44
+ )
45
+ else:
46
+ self._logger = None
47
+
48
+ def emit(self, record: ParseableEventRecord) -> None:
49
+ """
50
+ Emit a single record to Parseable.
51
+
52
+ Adds service_name, timestamp, and plugin_version if not already
53
+ present, then serialises to JSON and sends via the OTel logger.
54
+ Silently no-ops when logs are disabled (logger is None).
55
+ """
56
+ if self._logger is None:
57
+ return
58
+
59
+ record.setdefault("service_name", self._service_name) # type: ignore[misc]
60
+ record.setdefault("timestamp", _now_iso()) # type: ignore[misc]
61
+ record.setdefault("plugin_version", self._plugin_version) # type: ignore[misc]
62
+
63
+ body = json.dumps(record, default=str)
64
+
65
+ self._logger.emit(
66
+ timestamp=_now_ns(),
67
+ observed_timestamp=_now_ns(),
68
+ severity_number=SeverityNumber.INFO,
69
+ severity_text="INFO",
70
+ body=body,
71
+ attributes={"parseable.stream": "temporal-logs"},
72
+ )
73
+
74
+
75
+ # ── helpers ──────────────────────────────────────────────────────────────────
76
+
77
+ def _now_iso() -> str:
78
+ return datetime.now(timezone.utc).isoformat()
79
+
80
+
81
+ def _now_ns() -> int:
82
+ """Current time as nanoseconds since epoch (required by OTel APIs)."""
83
+ return time.time_ns()
@@ -0,0 +1 @@
1
+ PLUGIN_VERSION = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """
2
+ Activity interceptor.
3
+
4
+ Wraps every activity execution and emits three possible records to Parseable:
5
+
6
+ started — before the activity function runs
7
+ completed — after successful return
8
+ failed — after an exception (including ApplicationError retries)
9
+
10
+ Fields captured on every record:
11
+
12
+ type = "activity"
13
+ activity_name — activity function name
14
+ activity_id — unique ID assigned by Temporal
15
+ attempt — 1-based retry attempt number
16
+ workflow_id — parent workflow
17
+ run_id — parent run
18
+ workflow_name — parent workflow type name
19
+ duration_ms — wall-clock ms from started to completed/failed
20
+ error — stringified exception on failed records
21
+
22
+ Mirrors the TypeScript activity-interceptor.ts.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import time
28
+ from typing import Any
29
+
30
+ from temporalio import activity
31
+ from temporalio.worker import ActivityInboundInterceptor, ExecuteActivityInput
32
+
33
+ from ._emitter import ParseableEmitter, _now_iso
34
+
35
+
36
+ class ParseableActivityInterceptor(ActivityInboundInterceptor):
37
+ """
38
+ Inbound activity interceptor that emits structured records to Parseable.
39
+
40
+ One instance is created per activity execution by
41
+ ``ParseableWorkerInterceptor.intercept_activity``.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ next: ActivityInboundInterceptor,
47
+ emitter: ParseableEmitter,
48
+ ) -> None:
49
+ super().__init__(next)
50
+ self._emitter = emitter
51
+
52
+ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
53
+ info = activity.info()
54
+
55
+ base = {
56
+ "type": "activity",
57
+ "activity_name": info.activity_type,
58
+ "activity_id": info.activity_id,
59
+ "attempt": info.attempt,
60
+ "workflow_id": info.workflow_id,
61
+ "run_id": info.workflow_run_id,
62
+ "workflow_name": info.workflow_type,
63
+ }
64
+
65
+ # ── started ──────────────────────────────────────────────────────────
66
+ self._emitter.emit({**base, "status": "started", "timestamp": _now_iso()}) # type: ignore[arg-type]
67
+
68
+ start_ns = time.monotonic_ns()
69
+ try:
70
+ result = await self.next.execute_activity(input)
71
+ except Exception as exc:
72
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
73
+ # ── failed ───────────────────────────────────────────────────────
74
+ self._emitter.emit({ # type: ignore[arg-type]
75
+ **base,
76
+ "status": "failed",
77
+ "timestamp": _now_iso(),
78
+ "duration_ms": round(duration_ms, 3),
79
+ "error": str(exc),
80
+ })
81
+ raise
82
+
83
+ duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
84
+ # ── completed ────────────────────────────────────────────────────────
85
+ self._emitter.emit({ # type: ignore[arg-type]
86
+ **base,
87
+ "status": "completed",
88
+ "timestamp": _now_iso(),
89
+ "duration_ms": round(duration_ms, 3),
90
+ })
91
+ return result
@@ -0,0 +1,116 @@
1
+ """
2
+ Configuration for the Parseable Temporal plugin.
3
+
4
+ All settings are readable from environment variables with the PARSEABLE_
5
+ prefix, matching the TypeScript plugin's env-var convention. Values passed
6
+ directly to ParseableConfig take precedence over environment variables.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+
16
+ def _env(key: str, default: str) -> str:
17
+ return os.environ.get(key, default)
18
+
19
+
20
+ @dataclass
21
+ class LogsConfig:
22
+ """Configuration for the structured-log pipeline."""
23
+
24
+ #: Parseable stream name for log records.
25
+ stream: str = field(default_factory=lambda: _env("PARSEABLE_LOGS_STREAM", "temporal-logs"))
26
+ enabled: bool = field(
27
+ default_factory=lambda: _env("PARSEABLE_ENABLE_LOGS", "true").lower() != "false"
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class TracesConfig:
33
+ """Configuration for the OTel trace pipeline."""
34
+
35
+ #: Parseable stream name for trace spans.
36
+ stream: str = field(
37
+ default_factory=lambda: _env("PARSEABLE_TRACES_STREAM", "temporal-traces")
38
+ )
39
+ enabled: bool = field(
40
+ default_factory=lambda: _env("PARSEABLE_ENABLE_TRACES", "true").lower() != "false"
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class ParseableConfig:
46
+ """
47
+ Full configuration for ParseablePlugin.
48
+
49
+ Usage::
50
+
51
+ config = ParseableConfig(
52
+ service_name="my-worker",
53
+ endpoint="https://parseable.example.com",
54
+ username="admin",
55
+ password="secret",
56
+ )
57
+
58
+ All arguments fall back to environment variables when omitted:
59
+
60
+ ===================== =========================== =======================
61
+ Argument Environment variable Default
62
+ ===================== =========================== =======================
63
+ endpoint PARSEABLE_URL http://localhost:8000
64
+ username PARSEABLE_USERNAME admin
65
+ password PARSEABLE_PASSWORD admin
66
+ service_name PARSEABLE_SERVICE_NAME temporal-worker
67
+ ===================== =========================== =======================
68
+ """
69
+
70
+ #: Parseable base URL, e.g. ``http://parseable.example:8000``.
71
+ #: Logs are POSTed to ``{endpoint}/v1/logs``,
72
+ #: traces to ``{endpoint}/v1/traces``.
73
+ endpoint: str = field(
74
+ default_factory=lambda: _env("PARSEABLE_URL", "http://localhost:8000")
75
+ )
76
+
77
+ #: HTTP Basic auth username.
78
+ username: str = field(
79
+ default_factory=lambda: _env("PARSEABLE_USERNAME", "admin")
80
+ )
81
+
82
+ #: HTTP Basic auth password.
83
+ password: str = field(
84
+ default_factory=lambda: _env("PARSEABLE_PASSWORD", "admin")
85
+ )
86
+
87
+ #: Becomes the ``service.name`` OTel resource attribute and the
88
+ #: ``service_name`` field in every log record.
89
+ service_name: str = field(
90
+ default_factory=lambda: _env("PARSEABLE_SERVICE_NAME", "temporal-worker")
91
+ )
92
+
93
+ #: Structured-log pipeline config. Pass ``logs=None`` to disable logs.
94
+ logs: Optional[LogsConfig] = field(default_factory=LogsConfig)
95
+
96
+ #: OTel trace pipeline config. Pass ``traces=None`` to disable traces.
97
+ traces: Optional[TracesConfig] = field(default_factory=TracesConfig)
98
+
99
+ # ── Derived helpers ──────────────────────────────────────────────────────
100
+
101
+ @property
102
+ def logs_endpoint(self) -> str:
103
+ """Full OTLP/HTTP logs endpoint."""
104
+ return f"{self.endpoint.rstrip('/')}/v1/logs"
105
+
106
+ @property
107
+ def traces_endpoint(self) -> str:
108
+ """Full OTLP/HTTP traces endpoint."""
109
+ return f"{self.endpoint.rstrip('/')}/v1/traces"
110
+
111
+ @property
112
+ def auth_header(self) -> str:
113
+ """Base64-encoded HTTP Basic auth header value."""
114
+ import base64
115
+ token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
116
+ return f"Basic {token}"
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Optional, Sequence
7
+
8
+ from opentelemetry.sdk.trace import ReadableSpan
9
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
10
+ from opentelemetry.sdk.resources import Resource, SERVICE_NAME
11
+ from opentelemetry.sdk.trace import TracerProvider
12
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
13
+ from opentelemetry.sdk._logs import LoggerProvider
14
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
15
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
16
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
17
+
18
+ from .config import ParseableConfig
19
+ from ._version import PLUGIN_VERSION
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _Primitive = (str, int, float, bool)
24
+
25
+
26
+ def _sanitize_value(v: Any) -> Any:
27
+ if v is None:
28
+ return None
29
+ if isinstance(v, _Primitive):
30
+ return v
31
+ if isinstance(v, datetime):
32
+ return v.isoformat()
33
+ if isinstance(v, (list, tuple)):
34
+ sanitised = [_sanitize_value(item) for item in v]
35
+ cleaned = [item for item in sanitised if item is not None]
36
+ if all(isinstance(item, _Primitive) for item in cleaned):
37
+ return cleaned
38
+ return json.dumps(cleaned)
39
+ if isinstance(v, dict):
40
+ return json.dumps(v, default=str)
41
+ return str(v)
42
+
43
+
44
+ def _sanitize_span(span: ReadableSpan) -> ReadableSpan:
45
+ if not span.attributes:
46
+ return span
47
+ clean: Dict[str, Any] = {}
48
+ for key, value in span.attributes.items():
49
+ sanitised = _sanitize_value(value)
50
+ if sanitised is not None:
51
+ clean[key] = sanitised
52
+ try:
53
+ object.__setattr__(span, "_attributes", clean)
54
+ except (AttributeError, TypeError):
55
+ pass
56
+ return span
57
+
58
+
59
+ class SanitizingSpanExporter(SpanExporter):
60
+ def __init__(self, delegate: SpanExporter) -> None:
61
+ self._delegate = delegate
62
+
63
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
64
+ sanitised = [_sanitize_span(span) for span in spans]
65
+ return self._delegate.export(sanitised)
66
+
67
+ def shutdown(self) -> None:
68
+ self._delegate.shutdown()
69
+
70
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
71
+ return self._delegate.force_flush(timeout_millis)
72
+
73
+
74
+ def _resource(config: ParseableConfig) -> Resource:
75
+ return Resource.create({
76
+ SERVICE_NAME: config.service_name,
77
+ "parseable.plugin.version": PLUGIN_VERSION,
78
+ })
79
+
80
+
81
+ def build_tracer_provider(config: ParseableConfig) -> Optional[TracerProvider]:
82
+ if config.traces is None or not config.traces.enabled:
83
+ return None
84
+
85
+ endpoint = config.traces_endpoint
86
+ stream = config.traces.stream
87
+ logger.info("Parseable traces endpoint: %s (stream=%s)", endpoint, stream)
88
+
89
+ otlp_exporter = OTLPSpanExporter(
90
+ endpoint=endpoint,
91
+ headers={
92
+ "Authorization": config.auth_header,
93
+ "X-P-Stream": stream,
94
+ "X-P-Log-Source": "otel-traces", # tells Parseable this is OTLP traces
95
+ },
96
+ )
97
+ sanitizing_exporter = SanitizingSpanExporter(otlp_exporter)
98
+ provider = TracerProvider(resource=_resource(config))
99
+ provider.add_span_processor(BatchSpanProcessor(sanitizing_exporter))
100
+ return provider
101
+
102
+
103
+ def build_logger_provider(config: ParseableConfig) -> Optional[LoggerProvider]:
104
+ if config.logs is None or not config.logs.enabled:
105
+ return None
106
+
107
+ endpoint = config.logs_endpoint
108
+ stream = config.logs.stream
109
+ logger.info("Parseable logs endpoint: %s (stream=%s)", endpoint, stream)
110
+
111
+ otlp_log_exporter = OTLPLogExporter(
112
+ endpoint=endpoint,
113
+ headers={
114
+ "Authorization": config.auth_header,
115
+ "X-P-Stream": stream,
116
+ "X-P-Log-Source": "otel-logs", # tells Parseable this is OTLP logs
117
+ },
118
+ )
119
+ provider = LoggerProvider(resource=_resource(config))
120
+ provider.add_log_record_processor(BatchLogRecordProcessor(otlp_log_exporter))
121
+ return provider
@@ -0,0 +1,70 @@
1
+ """
2
+ Parseable event record schema.
3
+
4
+ Mirrors the TypeScript ParseableEventRecord type from types.ts exactly.
5
+ Every field that can appear in a log line sent to the temporal-logs stream
6
+ is declared here. Optional fields are absent from the dict when not set —
7
+ callers use TypedDict with total=False sections for optional keys.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Literal, Optional
13
+ from typing_extensions import TypedDict, Required
14
+
15
+
16
+ EventType = Literal[
17
+ "activity",
18
+ "workflow",
19
+ "user_event",
20
+ "signal",
21
+ "query",
22
+ "update",
23
+ "child_workflow",
24
+ "continue_as_new",
25
+ ]
26
+
27
+ EventStatus = Literal["started", "completed", "failed"]
28
+ EventDirection = Literal["inbound", "outbound"]
29
+
30
+
31
+ class ParseableEventRecord(TypedDict, total=False):
32
+ """
33
+ Flat log record emitted to the Parseable temporal-logs stream.
34
+
35
+ Required fields are always present. Optional fields are included only when
36
+ relevant to the event type (e.g. activity_name is set for activity records,
37
+ event_name for user_event records, etc.).
38
+ """
39
+
40
+ # ── Required on every record ────────────────────────────────────────────
41
+ type: Required[EventType]
42
+ service_name: Required[str]
43
+ timestamp: Required[str] # ISO 8601
44
+ workflow_id: Required[str]
45
+ run_id: Required[str]
46
+ workflow_name: Required[str]
47
+
48
+ # ── Present on all records except user_event ────────────────────────────
49
+ status: EventStatus
50
+
51
+ # ── Activity records only ───────────────────────────────────────────────
52
+ activity_name: str
53
+ activity_id: str
54
+ attempt: int # 1-based
55
+
56
+ # ── Completion / failure fields ─────────────────────────────────────────
57
+ duration_ms: float
58
+ error: str # failure message
59
+
60
+ # ── Message records (signal/query/update/child_workflow/continue_as_new) ─
61
+ direction: EventDirection
62
+ message_name: str # signal/query/update name or child type
63
+ target_workflow_id: str # outbound signals/child workflows
64
+
65
+ # ── User-event records only ─────────────────────────────────────────────
66
+ event_name: str
67
+ event_data: Any # arbitrary JSON-serialisable payload
68
+
69
+ # ── Plugin metadata ─────────────────────────────────────────────────────
70
+ plugin_version: str