redberry-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.
- redberry_sdk/__init__.py +20 -0
- redberry_sdk/_call_role.py +78 -0
- redberry_sdk/_config.py +181 -0
- redberry_sdk/_coverage.py +70 -0
- redberry_sdk/_emit.py +541 -0
- redberry_sdk/_emit_aws.py +254 -0
- redberry_sdk/_emit_azure.py +214 -0
- redberry_sdk/_env.py +20 -0
- redberry_sdk/_sdk.py +364 -0
- redberry_sdk/_span_event_logger.py +96 -0
- redberry_sdk/_version.py +3 -0
- redberry_sdk/hasher/__init__.py +115 -0
- redberry_sdk/hasher/_agents.py +62 -0
- redberry_sdk/hasher/_azure.py +396 -0
- redberry_sdk/hasher/_canonical.py +156 -0
- redberry_sdk/hasher/_config.py +165 -0
- redberry_sdk/hasher/_crypto.py +88 -0
- redberry_sdk/hasher/_envelope.py +84 -0
- redberry_sdk/hasher/_http.py +134 -0
- redberry_sdk/hasher/_processor.py +381 -0
- redberry_sdk/hasher/_report.py +143 -0
- redberry_sdk/hasher/_sigv4.py +132 -0
- redberry_sdk/hasher/_span.py +101 -0
- redberry_sdk/hasher/stores.py +586 -0
- redberry_sdk/instrumentors/__init__.py +236 -0
- redberry_sdk/instrumentors/_base.py +77 -0
- redberry_sdk/instrumentors/_expected.py +202 -0
- redberry_sdk/instrumentors/_native.py +97 -0
- redberry_sdk/instrumentors/_request_snapshot.py +449 -0
- redberry_sdk/instrumentors/_stream_capture.py +363 -0
- redberry_sdk/instrumentors/_tool_definitions.py +403 -0
- redberry_sdk/instrumentors/_wrapt_fallback.py +155 -0
- redberry_sdk/integrations/__init__.py +1 -0
- redberry_sdk/integrations/openai_agents.py +450 -0
- redberry_sdk/valp.py +536 -0
- redberry_sdk-0.1.0.dist-info/METADATA +157 -0
- redberry_sdk-0.1.0.dist-info/RECORD +41 -0
- redberry_sdk-0.1.0.dist-info/WHEEL +5 -0
- redberry_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
- redberry_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
- redberry_sdk-0.1.0.dist-info/top_level.txt +1 -0
redberry_sdk/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Redberry SDK: records an AI agent's model and tool calls and sends them to the customer's Redberry install.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import redberry_sdk
|
|
6
|
+
redberry_sdk.init(agent="my-agent")
|
|
7
|
+
|
|
8
|
+
After init(), model calls made through supported libraries (OpenAI, Anthropic, LangChain and others) are
|
|
9
|
+
recorded with OpenTelemetry and sent to the ingest service of the Redberry install in the customer's own cloud
|
|
10
|
+
(Google Cloud, AWS or Azure), or to an explicit endpoint.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ._sdk import ContentCaptureDisabledError, MissingInstrumentationError, flush, flush_after, init, get_tracer, shutdown
|
|
14
|
+
from ._call_role import internal_call
|
|
15
|
+
from ._emit import dropped_spans
|
|
16
|
+
from ._config import SDKConfig, EvaluationTopology
|
|
17
|
+
from ._coverage import CoverageReport
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__", "init", "dropped_spans", "flush", "flush_after", "get_tracer", "shutdown", "internal_call", "MissingInstrumentationError", "ContentCaptureDisabledError", "SDKConfig", "EvaluationTopology", "CoverageReport"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``internal_call()``: mark the model calls an agent makes for itself.
|
|
2
|
+
|
|
3
|
+
A control's verifier, a judge, a planner or a summariser is a model call whose output stays inside
|
|
4
|
+
the agent. Recorded like any other call, its text became a message to the user: RAP's G3 verifier
|
|
5
|
+
put 11 ``ACCEPT`` / ``REJECT`` "replies" into one airline trace. Wrap such a call::
|
|
6
|
+
|
|
7
|
+
with redberry_sdk.internal_call("verifier"):
|
|
8
|
+
resp = client.chat.completions.create(...)
|
|
9
|
+
|
|
10
|
+
and every span started inside the block carries ``redberry.call_role = "internal"`` and
|
|
11
|
+
``redberry.call_name = "verifier"``. The normaliser then records the call under the trace's
|
|
12
|
+
``metadata.internal_calls`` and not as an event (``redberry_normaliser._call_role``).
|
|
13
|
+
|
|
14
|
+
Mark only calls whose output the agent consumes itself. A call whose tool calls the agent
|
|
15
|
+
dispatches is the agent acting, and must not be marked.
|
|
16
|
+
|
|
17
|
+
The marker is a ``contextvars`` value, so it follows the call into ``await`` and stays out of
|
|
18
|
+
other threads and tasks. Without ``redberry_sdk.init()`` it does nothing, and costs nothing.
|
|
19
|
+
|
|
20
|
+
``redberry.*``, not ``valp.*``: ``valp.*`` is VALP's frozen evidence vocabulary
|
|
21
|
+
(``redberry_sdk.valp``), and a new key there is a VALP specification change.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextvars
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from typing import Iterator
|
|
28
|
+
|
|
29
|
+
from opentelemetry.context import Context
|
|
30
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
31
|
+
|
|
32
|
+
#: Read by ``redberry_normaliser._call_role``; kept in step with it.
|
|
33
|
+
CALL_ROLE_ATTR = "redberry.call_role"
|
|
34
|
+
CALL_NAME_ATTR = "redberry.call_name"
|
|
35
|
+
INTERNAL = "internal"
|
|
36
|
+
|
|
37
|
+
_role: contextvars.ContextVar[tuple[str, str | None] | None] = contextvars.ContextVar(
|
|
38
|
+
"redberry_call_role", default=None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def internal_call(name: str | None = None) -> Iterator[None]:
|
|
43
|
+
"""Mark every span started inside the block as an internal call named ``name``."""
|
|
44
|
+
if name is not None and not isinstance(name, str):
|
|
45
|
+
raise TypeError(f"internal_call name must be a string, got {type(name).__name__}")
|
|
46
|
+
token = _role.set((INTERNAL, name))
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
finally:
|
|
50
|
+
_role.reset(token)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def current_call_role() -> tuple[str, str | None] | None:
|
|
54
|
+
"""``(role, name)`` inside ``internal_call()``, else None."""
|
|
55
|
+
return _role.get()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CallRoleSpanProcessor(SpanProcessor):
|
|
59
|
+
"""Stamps ``redberry.call_role`` / ``redberry.call_name`` at span start inside ``internal_call()``."""
|
|
60
|
+
|
|
61
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
62
|
+
role = _role.get()
|
|
63
|
+
if role is None:
|
|
64
|
+
return
|
|
65
|
+
attrs = span.attributes or {}
|
|
66
|
+
if CALL_ROLE_ATTR not in attrs:
|
|
67
|
+
span.set_attribute(CALL_ROLE_ATTR, role[0])
|
|
68
|
+
if role[1] and CALL_NAME_ATTR not in attrs:
|
|
69
|
+
span.set_attribute(CALL_NAME_ATTR, role[1])
|
|
70
|
+
|
|
71
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
78
|
+
return True
|
redberry_sdk/_config.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""SDK configuration — loaded from redberry.yaml or passed directly to init()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from . import _env
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EvaluationTopology(str, Enum):
|
|
16
|
+
REDBERRY_HOSTED = "redberry_hosted"
|
|
17
|
+
CLIENT_HOSTED = "client_hosted"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Where redberry-extract writes the manifest (redberry_extractor.cli).
|
|
21
|
+
DEFAULT_MANIFEST = ".redberry/manifest.json"
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SDKConfig:
|
|
25
|
+
# Identity
|
|
26
|
+
agent_id: str
|
|
27
|
+
task_class: str | None = None
|
|
28
|
+
|
|
29
|
+
# RAP run identifier (per scenario invocation). Set by the RAP outer loop
|
|
30
|
+
# via REDBERRY_RUN_ID, or passed explicitly. Reported as the redberry.run_id
|
|
31
|
+
# resource attribute; optional.
|
|
32
|
+
run_id: str | None = None
|
|
33
|
+
|
|
34
|
+
# OTel collector endpoint (defaults to local Docker stack)
|
|
35
|
+
otel_endpoint: str = "http://localhost:4318" # OTLP HTTP
|
|
36
|
+
|
|
37
|
+
# Whether to capture message content (prompts/completions).
|
|
38
|
+
# Setting False records metadata_only tier — content is not sent to collector.
|
|
39
|
+
capture_content: bool = True
|
|
40
|
+
|
|
41
|
+
# Topology controls what the Results Export service ships out of the stack.
|
|
42
|
+
topology: EvaluationTopology = EvaluationTopology.REDBERRY_HOSTED
|
|
43
|
+
|
|
44
|
+
# Evaluator endpoint (Topology 1 only).
|
|
45
|
+
evaluator_endpoint: str | None = None
|
|
46
|
+
|
|
47
|
+
# Declared annual interaction volume — feeds Contract 4.
|
|
48
|
+
declared_annual_volume: int | None = None
|
|
49
|
+
|
|
50
|
+
# Extra attributes added to every span as OTel resource attributes.
|
|
51
|
+
extra_resource_attributes: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
# VALP: the tool catalogue the insured confirmed at binding (tool_catalogue.confirmed.json).
|
|
54
|
+
# When set, execute_tool spans get valp.is_external / valp.is_mutating from it; when unset,
|
|
55
|
+
# they get neither (redberry_sdk.valp: omit rather than guess).
|
|
56
|
+
valp_tool_catalogue: str | None = None
|
|
57
|
+
|
|
58
|
+
# VALP: how long a run paused for a person's approval may wait before its interaction is closed
|
|
59
|
+
# as failed (redberry_sdk.integrations.openai_agents, "pending-approval TTL"). Default 7 days.
|
|
60
|
+
valp_pending_approval_ttl_s: float | None = None
|
|
61
|
+
|
|
62
|
+
# An agent whose provider library is installed without its instrumentation package would run
|
|
63
|
+
# unobserved (see instrumentors/_expected.py), so init() refuses to start. Set this (or
|
|
64
|
+
# REDBERRY_ALLOW_MISSING_INSTRUMENTATION=1) only where a subset is installed deliberately; init()
|
|
65
|
+
# then warns and logs at ERROR instead.
|
|
66
|
+
allow_missing_instrumentation: bool = False
|
|
67
|
+
|
|
68
|
+
# Content capture cannot be switched off silently (RAP docs/valp_single_chain_plan.md, A3): the
|
|
69
|
+
# evidence and pricing both need content. With it off (capture_content=False, or an inherited
|
|
70
|
+
# OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT other than "true") init() refuses to start,
|
|
71
|
+
# unless this is set (or REDBERRY_ALLOW_CONTENT_CAPTURE_OFF=1): a metadata-only run, chosen.
|
|
72
|
+
allow_content_capture_off: bool = False
|
|
73
|
+
|
|
74
|
+
# The agent's code version and its manifest, reported on every span as resource attributes
|
|
75
|
+
# (redberry.agent_code_version, redberry.manifest_hash: the SHA-256 of the manifest file's
|
|
76
|
+
# bytes, as VALP's genesis computes it) for VALP spec v2's observed fingerprint. Env:
|
|
77
|
+
# REDBERRY_AGENT_CODE_VERSION, REDBERRY_MANIFEST. The manifest defaults to
|
|
78
|
+
# .redberry/manifest.json when that file exists.
|
|
79
|
+
agent_code_version: str | None = None
|
|
80
|
+
manifest_path: str | None = None
|
|
81
|
+
|
|
82
|
+
def fingerprint_attributes(self) -> dict[str, str]:
|
|
83
|
+
"""``redberry.agent_code_version`` and ``redberry.manifest_hash``, where known."""
|
|
84
|
+
import hashlib
|
|
85
|
+
|
|
86
|
+
attrs: dict[str, str] = {}
|
|
87
|
+
if self.agent_code_version:
|
|
88
|
+
attrs["redberry.agent_code_version"] = self.agent_code_version
|
|
89
|
+
manifest = self.manifest_path or (DEFAULT_MANIFEST if Path(DEFAULT_MANIFEST).is_file() else None)
|
|
90
|
+
if manifest:
|
|
91
|
+
try:
|
|
92
|
+
attrs["redberry.manifest_hash"] = hashlib.sha256(Path(manifest).read_bytes()).hexdigest()
|
|
93
|
+
except OSError as e:
|
|
94
|
+
import logging
|
|
95
|
+
logging.getLogger("redberry.sdk").warning("cannot read the manifest %s: %s", manifest, e)
|
|
96
|
+
return attrs
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_yaml(cls, path: str | Path) -> "SDKConfig":
|
|
100
|
+
p = Path(path)
|
|
101
|
+
if not p.exists():
|
|
102
|
+
raise FileNotFoundError(f"Config file not found: {p}")
|
|
103
|
+
with p.open() as fh:
|
|
104
|
+
raw = yaml.safe_load(fh) or {}
|
|
105
|
+
sdk_cfg = raw.get("sdk", {})
|
|
106
|
+
return cls(
|
|
107
|
+
agent_id=sdk_cfg["agent_id"],
|
|
108
|
+
task_class=sdk_cfg.get("task_class"),
|
|
109
|
+
run_id=sdk_cfg.get("run_id"),
|
|
110
|
+
otel_endpoint=sdk_cfg.get("otel_endpoint", "http://localhost:4318"),
|
|
111
|
+
capture_content=sdk_cfg.get("capture_content", True),
|
|
112
|
+
topology=EvaluationTopology(
|
|
113
|
+
sdk_cfg.get("topology", EvaluationTopology.REDBERRY_HOSTED.value)
|
|
114
|
+
),
|
|
115
|
+
evaluator_endpoint=sdk_cfg.get("evaluator_endpoint"),
|
|
116
|
+
declared_annual_volume=sdk_cfg.get("declared_annual_volume"),
|
|
117
|
+
extra_resource_attributes=sdk_cfg.get("extra_resource_attributes", {}),
|
|
118
|
+
valp_tool_catalogue=sdk_cfg.get("valp_tool_catalogue"),
|
|
119
|
+
valp_pending_approval_ttl_s=sdk_cfg.get("valp_pending_approval_ttl_s"),
|
|
120
|
+
allow_missing_instrumentation=bool(sdk_cfg.get("allow_missing_instrumentation", False)),
|
|
121
|
+
allow_content_capture_off=bool(sdk_cfg.get("allow_content_capture_off", False)),
|
|
122
|
+
agent_code_version=sdk_cfg.get("agent_code_version"),
|
|
123
|
+
manifest_path=sdk_cfg.get("manifest_path"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def apply_env_overrides(self) -> "SDKConfig":
|
|
127
|
+
"""Fill any missing fields from the REDBERRY_* (or the earlier OTELLINE_*) environment variables.
|
|
128
|
+
|
|
129
|
+
Resolution order is: explicit kwargs / YAML > env vars > defaults.
|
|
130
|
+
Called from `init()` after the config is otherwise resolved, so callers
|
|
131
|
+
can opt out by passing values explicitly.
|
|
132
|
+
|
|
133
|
+
Env vars:
|
|
134
|
+
REDBERRY_AGENT_ID — agent_id
|
|
135
|
+
REDBERRY_RUN_ID — run_id
|
|
136
|
+
REDBERRY_TASK_CLASS — task_class
|
|
137
|
+
REDBERRY_OTEL_ENDPOINT — otel_endpoint
|
|
138
|
+
VALP_TOOL_CATALOGUE — valp_tool_catalogue
|
|
139
|
+
VALP_PENDING_APPROVAL_TTL_S — valp_pending_approval_ttl_s
|
|
140
|
+
REDBERRY_ALLOW_MISSING_INSTRUMENTATION=1 — allow_missing_instrumentation
|
|
141
|
+
REDBERRY_ALLOW_CONTENT_CAPTURE_OFF=1 — allow_content_capture_off
|
|
142
|
+
REDBERRY_AGENT_CODE_VERSION — agent_code_version
|
|
143
|
+
REDBERRY_MANIFEST — manifest_path
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def _get_stripped_env(name: str) -> str | None:
|
|
147
|
+
value = _env.get(name)
|
|
148
|
+
if value is None:
|
|
149
|
+
return None
|
|
150
|
+
value = value.strip()
|
|
151
|
+
return value or None
|
|
152
|
+
|
|
153
|
+
if not self.agent_id:
|
|
154
|
+
env_id = _get_stripped_env("REDBERRY_AGENT_ID")
|
|
155
|
+
if env_id is not None:
|
|
156
|
+
self.agent_id = env_id
|
|
157
|
+
if self.run_id is None:
|
|
158
|
+
self.run_id = _get_stripped_env("REDBERRY_RUN_ID")
|
|
159
|
+
if self.task_class is None:
|
|
160
|
+
self.task_class = _get_stripped_env("REDBERRY_TASK_CLASS")
|
|
161
|
+
env_endpoint = _get_stripped_env("REDBERRY_OTEL_ENDPOINT")
|
|
162
|
+
if env_endpoint is not None and self.otel_endpoint == "http://localhost:4318":
|
|
163
|
+
self.otel_endpoint = env_endpoint
|
|
164
|
+
if self.valp_tool_catalogue is None:
|
|
165
|
+
self.valp_tool_catalogue = _get_stripped_env("VALP_TOOL_CATALOGUE")
|
|
166
|
+
if self.valp_pending_approval_ttl_s is None and (ttl := _get_stripped_env("VALP_PENDING_APPROVAL_TTL_S")):
|
|
167
|
+
try:
|
|
168
|
+
self.valp_pending_approval_ttl_s = float(ttl)
|
|
169
|
+
except ValueError:
|
|
170
|
+
raise ValueError(f"VALP_PENDING_APPROVAL_TTL_S must be a number of seconds, not {ttl!r}") from None
|
|
171
|
+
if not self.allow_missing_instrumentation and _get_stripped_env(
|
|
172
|
+
"REDBERRY_ALLOW_MISSING_INSTRUMENTATION") in ("1", "true", "yes"):
|
|
173
|
+
self.allow_missing_instrumentation = True
|
|
174
|
+
if not self.allow_content_capture_off and _get_stripped_env(
|
|
175
|
+
"REDBERRY_ALLOW_CONTENT_CAPTURE_OFF") in ("1", "true", "yes"):
|
|
176
|
+
self.allow_content_capture_off = True
|
|
177
|
+
if self.agent_code_version is None:
|
|
178
|
+
self.agent_code_version = _get_stripped_env("REDBERRY_AGENT_CODE_VERSION")
|
|
179
|
+
if self.manifest_path is None:
|
|
180
|
+
self.manifest_path = _get_stripped_env("REDBERRY_MANIFEST")
|
|
181
|
+
return self
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Coverage report — emitted at init() time and persisted to .redberry/coverage.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .instrumentors import InstrumentorStatus
|
|
14
|
+
|
|
15
|
+
_log = logging.getLogger("redberry.sdk")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CoverageReport:
|
|
20
|
+
agent_id: str
|
|
21
|
+
generated_at: str
|
|
22
|
+
instrumentors: list[dict]
|
|
23
|
+
tool_gaps: list[str] = field(default_factory=list) # tools in manifest not wrapped
|
|
24
|
+
notes: list[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def build(
|
|
28
|
+
cls,
|
|
29
|
+
agent_id: str,
|
|
30
|
+
statuses: list[InstrumentorStatus],
|
|
31
|
+
tool_gaps: list[str] | None = None,
|
|
32
|
+
) -> CoverageReport:
|
|
33
|
+
return cls(
|
|
34
|
+
agent_id=agent_id,
|
|
35
|
+
generated_at=datetime.now(UTC).isoformat(),
|
|
36
|
+
instrumentors=[asdict(s) for s in statuses],
|
|
37
|
+
tool_gaps=tool_gaps or [],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def print_summary(self) -> None:
|
|
41
|
+
enabled = [i for i in self.instrumentors if i["enabled"]]
|
|
42
|
+
disabled = [i for i in self.instrumentors if not i["enabled"]]
|
|
43
|
+
print(f"\n[redberry] Coverage report for agent '{self.agent_id}'")
|
|
44
|
+
print(f" Enabled ({len(enabled)}): " + ", ".join(i["name"] for i in enabled))
|
|
45
|
+
if disabled:
|
|
46
|
+
print(f" Skipped ({len(disabled)}): " + ", ".join(
|
|
47
|
+
f"{i['name']} ({i['reason']})" for i in disabled
|
|
48
|
+
))
|
|
49
|
+
if self.tool_gaps:
|
|
50
|
+
print(f" Tool gaps ({len(self.tool_gaps)}): " + ", ".join(self.tool_gaps))
|
|
51
|
+
print(" → Run the manifest extractor to generate wrappers for these tools.")
|
|
52
|
+
print()
|
|
53
|
+
|
|
54
|
+
def persist(self, base_dir: Path | None = None) -> Path | None:
|
|
55
|
+
"""Write the report to ``<cwd>/.redberry/coverage.json``, else to the temp directory (a read-only
|
|
56
|
+
working directory: AWS Lambda's ``/var/task``). Never raises: the report is a convenience, and
|
|
57
|
+
``init()`` must not fail the agent over it. Returns where it was written, or None."""
|
|
58
|
+
text = json.dumps(asdict(self), indent=2)
|
|
59
|
+
candidates = [base_dir] if base_dir else [Path(os.getcwd()) / ".redberry",
|
|
60
|
+
Path(tempfile.gettempdir()) / ".redberry"]
|
|
61
|
+
for target_dir in candidates:
|
|
62
|
+
try:
|
|
63
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
out = target_dir / "coverage.json"
|
|
65
|
+
out.write_text(text)
|
|
66
|
+
return out
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
_log.info("redberry_sdk: coverage report not written (no writable directory)")
|
|
70
|
+
return None
|