1212# See the License for the specific language governing permissions and
1313# limitations under the License.
1414
15+ import functools
1516import json
1617import logging
1718import os
@@ -81,7 +82,9 @@ def collect(obj: Any) -> None:
8182 {
8283 "id" : k [0 ],
8384 "message" : k [1 ],
84- "context" : k [2 ],
85+ "context" : vs [0 ].get (
86+ "context"
87+ ), # preserve original dict, not JSON string
8588 "signal_name" : k [3 ],
8689 "signal_type" : k [4 ],
8790 "count" : len (vs ),
@@ -111,10 +114,88 @@ def _format_violations(violations: list) -> str:
111114 return "\n " .join (lines )
112115
113116
117+ class LiveCheckError (AssertionError ):
118+ """Raised by :meth:`WeaverLiveCheck.end_and_check` when semconv violations are found.
119+
120+ The full :class:`LiveCheckReport` is attached as :attr:`report` for
121+ structured inspection beyond the human-readable message::
122+
123+ with pytest.raises(LiveCheckError) as exc_info:
124+ weaver.end_and_check()
125+
126+ err = exc_info.value
127+ assert any(
128+ v["id"] == "my_policy_check"
129+ and v["context"]["attribute_name"] == "my.attribute"
130+ for v in err.report.violations
131+ )
132+ """
133+
134+ def __init__ (self , message : str , report : "LiveCheckReport" ) -> None :
135+ super ().__init__ (message )
136+ self .report = report
137+
138+
139+ class LiveCheckReport :
140+ """The result of a weaver live-check run.
141+
142+ Provides structured access to violations and the full raw JSON report.
143+
144+ See https://github.com/open-telemetry/weaver/tree/main/crates/weaver_live_check#output
145+ for the full report structure.
146+
147+ Example — asserting on metrics statistics::
148+
149+ report = weaver.end()
150+ seen = report["statistics"]["seen_registry_metrics"]
151+ assert seen.get("http.server.request.duration") == 1
152+
153+ Example — asserting on violations::
154+
155+ report = weaver.end()
156+ assert any(
157+ v["id"] == "my_policy_check"
158+ and v["context"]["attribute_name"] == "my.attribute"
159+ for v in report.violations
160+ )
161+ """
162+
163+ def __init__ (self , report : dict [str , Any ]) -> None :
164+ self ._report = report
165+
166+ @functools .cached_property
167+ def violations (self ) -> list [dict [str , Any ]]:
168+ """Deduplicated list of semconv violations found in the report.
169+
170+ Each item is a dict with keys: ``id``, ``message``, ``context``
171+ (the raw context dict from weaver, e.g. ``{"attribute_name": "foo"}``),
172+ ``signal_name``, ``signal_type``, ``count``.
173+ """
174+ return _extract_violations (self ._report )
175+
176+ def __getitem__ (self , key : str ) -> Any :
177+ return self ._report [key ]
178+
179+ def get (self , key : str , default : Any = None ) -> Any :
180+ return self ._report .get (key , default )
181+
182+ def __contains__ (self , key : object ) -> bool :
183+ return key in self ._report
184+
185+ def __repr__ (self ) -> str :
186+ n = len (self .violations )
187+ return f"LiveCheckReport({ n } violation{ 's' if n != 1 else '' } )"
188+
189+
190+ # NOTE: WeaverLiveCheck is experimental and its API is subject to change.
114191class WeaverLiveCheck :
115192 """Runs ``weaver registry live-check`` as a subprocess and validates
116193 OTLP telemetry against OpenTelemetry semantic conventions.
117194
195+ .. note::
196+ This class is experimental and its API is subject to change without notice.
197+
198+
118199 Requires the ``weaver`` binary on PATH:
119200 https://github.com/open-telemetry/weaver/releases
120201
@@ -128,27 +209,36 @@ def test_my_telemetry(self):
128209 # ... configure provider, emit telemetry ...
129210 provider.force_flush()
130211
131- # Signals weaver to stop, raises AssertionError listing violations
132- # if any, or returns the raw JSON report on success.
212+ # Signals weaver to stop, raises LiveCheckError listing violations
213+ # if any, or returns a LiveCheckReport on success.
133214 report = weaver.end_and_check()
134215 # __exit__ calls close(), which is idempotent if end_and_check() was already called
135216
136- :meth:`end_and_check` returns the raw weaver JSON report when weaver exits
137- successfully (exit code 0). Use it for custom assertions on the report
138- content beyond the built-in violation check::
217+ Use :meth:`end` when you need the full :class:`LiveCheckReport`
218+ regardless of whether violations were found — for example, to assert that
219+ specific metrics were observed or to inspect violation fields directly::
220+
221+ with WeaverLiveCheck() as weaver:
222+ # ... configure provider, emit telemetry ...
223+ provider.force_flush()
224+ report = weaver.end()
139225
140- report = weaver.end_and_check()
141- # report is the raw JSON dict from weaver; inspect it as needed, e.g.:
142- self.assertIn("some_signal", str(report))
226+ seen_metrics = report["statistics"]["seen_registry_metrics"]
227+ assert seen_metrics.get("http.server.request.duration") == 1
143228
144229 Lifecycle:
145230 - :meth:`start` — launches weaver and waits for it to become ready.
146231 - :attr:`otlp_endpoint` — gRPC OTLP endpoint to point exporters at.
147- - :meth:`end_and_check` — signals weaver to stop, collects the report, and
148- raises :class:`AssertionError` with a human-readable violation list if weaver
149- exits non-zero. Returns the raw report dict on success.
150- - :meth:`close` — calls :meth:`end_and_check` then terminates the process.
151- Idempotent; safe to call even if :meth:`end_and_check` was already called.
232+ - :meth:`end` — signals weaver to stop and always returns a
233+ :class:`LiveCheckReport`. Never raises for semconv violations; use
234+ this when you want to write your own assertions.
235+ - :meth:`end_and_check` — signals weaver to stop and raises
236+ :class:`LiveCheckError` with a human-readable violation list and the
237+ full report attached if weaver exits non-zero. Returns a
238+ :class:`LiveCheckReport` on success.
239+ - :meth:`close` — stops weaver if not already stopped and terminates the
240+ process. Never raises for semconv violations. Idempotent; safe to
241+ call even if :meth:`end_and_check` or :meth:`end` was already called.
152242 """
153243
154244 def __init__ (
@@ -247,25 +337,22 @@ def _wait_for_ready(self, timeout: int = 60) -> None:
247337 def otlp_endpoint (self ) -> str :
248338 return f"http://localhost:{ self ._otlp_port } "
249339
250- def end_and_check (self , timeout : int = 30 ) -> dict [str , Any ]:
251- if self ._stopped :
252- logger .warning (
253- "end_and_check() called after weaver already stopped; returning empty report"
254- )
255- return {}
256- self ._stopped = True
340+ def _do_stop (self , timeout : int ) -> tuple ["LiveCheckReport" , int ]:
341+ """POST /stop, wait for the process to exit, return (report, exit_code).
257342
343+ Raises for infrastructure errors (HTTP failure, process communication).
344+ Never raises for semconv violations.
345+ """
258346 if not self ._ready :
259347 raise RuntimeError (
260348 "WeaverLiveCheck process did not start successfully"
261349 )
262-
263350 try :
264351 response = post (
265352 f"http://localhost:{ self ._admin_port } /stop" , timeout = 5
266353 )
267354 response .raise_for_status ()
268- report = response .json ()
355+ report = LiveCheckReport ( response .json () )
269356 assert self ._process is not None
270357 exit_code = self ._process .wait (timeout = timeout )
271358 except Exception as e :
@@ -274,13 +361,58 @@ def end_and_check(self, timeout: int = 30) -> dict[str, Any]:
274361 "Error communicating with weaver: %s, logs: %s" , e , logs
275362 )
276363 raise
364+ return report , exit_code
365+
366+ def end (self , timeout : int = 30 ) -> "LiveCheckReport" :
367+ """Signal weaver to stop and return the full :class:`LiveCheckReport`.
368+
369+ Never raises for semconv violations — use this when you want to write
370+ your own assertions against :attr:`LiveCheckReport.violations` or the
371+ raw report data.
372+
373+ Raises :exc:`RuntimeError` for infrastructure problems (weaver failed
374+ to start, HTTP communication error, etc.).
375+
376+ See https://github.com/open-telemetry/weaver/tree/main/crates/weaver_live_check#output
377+ for the report structure.
378+ """
379+ if self ._stopped :
380+ logger .warning (
381+ "end() called after weaver already stopped; returning empty report"
382+ )
383+ return LiveCheckReport ({})
384+ self ._stopped = True
385+ report , _ = self ._do_stop (timeout )
386+ return report
277387
388+ def end_and_check (self , timeout : int = 30 ) -> "LiveCheckReport" :
389+ """Signal weaver to stop and assert no semconv violations were found.
390+
391+ Returns the :class:`LiveCheckReport` when weaver exits successfully
392+ (exit code 0).
393+
394+ Does **not** return if weaver exits with a non-zero status — raises
395+ :exc:`LiveCheckError` (a subclass of :exc:`AssertionError`) with a
396+ human-readable list of violations and the full :class:`LiveCheckReport`
397+ attached as :attr:`LiveCheckError.report`.
398+ Use :meth:`end` if you need the report regardless of violations.
399+
400+ Raises :exc:`RuntimeError` for infrastructure problems (weaver failed
401+ to start, HTTP communication error, etc.).
402+ """
403+ if self ._stopped :
404+ logger .warning (
405+ "end_and_check() called after weaver already stopped; returning empty report"
406+ )
407+ return LiveCheckReport ({})
408+ self ._stopped = True
409+ report , exit_code = self ._do_stop (timeout )
278410 if exit_code == 0 :
411+ # Success — no violations found, no errors communicating with weaver
279412 return report
280-
281- violations = _extract_violations (report )
282- raise AssertionError (
283- f"Semconv violations found:\n { _format_violations (violations )} "
413+ raise LiveCheckError (
414+ f"Semconv violations found:\n { _format_violations (report .violations )} " ,
415+ report ,
284416 )
285417
286418 def _read_weaver_logs (self ) -> str | None :
@@ -296,12 +428,24 @@ def _read_weaver_logs(self) -> str | None:
296428 return None
297429
298430 def close (self ) -> None :
299- try :
300- self .end_and_check ()
301- finally :
302- if self ._process and self ._process .poll () is None :
303- self ._process .terminate ()
431+ """Stop weaver and clean up the process.
432+
433+ If weaver has not been stopped yet, sends the ``/stop`` signal and
434+ waits for the process to exit. Never raises for semconv violations.
435+ Idempotent — safe to call multiple times or after :meth:`end` /
436+ :meth:`end_and_check` has already been called.
437+ """
438+ if not self ._stopped :
439+ self ._stopped = True
440+ if self ._ready :
304441 try :
305- self ._process .wait (timeout = 5 )
306- except subprocess .TimeoutExpired :
307- self ._process .kill ()
442+ self ._do_stop (timeout = 30 )
443+ return # process already exited cleanly
444+ except Exception as e :
445+ logger .debug ("Error stopping weaver during close: %s" , e )
446+ if self ._process and self ._process .poll () is None :
447+ self ._process .terminate ()
448+ try :
449+ self ._process .wait (timeout = 5 )
450+ except subprocess .TimeoutExpired :
451+ self ._process .kill ()
0 commit comments