argus-sdk 0.1.0__tar.gz

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,45 @@
1
+ # ── Python ────────────────────────────────────────────
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .venv/
7
+ venv/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ htmlcov/
15
+ .coverage
16
+
17
+ # ── Go ────────────────────────────────────────────────
18
+ server/bin/
19
+ *.exe
20
+ *.test
21
+
22
+ # ── Node / Next.js ────────────────────────────────────
23
+ ui/node_modules/
24
+ ui/.next/
25
+ ui/out/
26
+ ui/.env.local
27
+
28
+ # ── General ───────────────────────────────────────────
29
+ .env
30
+ .env.*
31
+ !.env.example
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # ── SQLite (runtime data, not schema) ─────────────────
36
+ *.db
37
+ *.db-shm
38
+ *.db-wal
39
+
40
+ # ── Claude context (local only) ───────────────────────
41
+ CLAUDE.md
42
+
43
+ # ── Reference docs (not source code) ─────────────────
44
+ *.docx
45
+ *.pdf
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: argus-sdk
3
+ Version: 0.1.0
4
+ Summary: Drop-in LLM behavioral drift detection
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: anthropic>=0.25
7
+ Requires-Dist: httpx>=0.27
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff>=0.4; extra == 'dev'
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def patch(endpoint: str = "http://localhost:4000", client: Any = None) -> None:
7
+ """Instrument LLM clients to send signal events to the Argus server.
8
+
9
+ Usage (auto — instruments all future clients):
10
+ from argus_sdk import patch
11
+ patch(endpoint="http://localhost:4000")
12
+
13
+ import anthropic
14
+ client = anthropic.Anthropic() # automatically instrumented
15
+
16
+ Usage (explicit — instrument a specific instance):
17
+ patch(endpoint="http://localhost:4000", client=my_client)
18
+ """
19
+ _endpoint = endpoint.rstrip("/")
20
+
21
+ if client is not None:
22
+ _patch_instance(client, _endpoint)
23
+ return
24
+
25
+ _try_patch_anthropic_class(_endpoint)
26
+ _try_patch_openai_class(_endpoint)
27
+
28
+
29
+ def _patch_instance(client: Any, endpoint: str) -> None:
30
+ module = type(client).__module__ or ""
31
+ if "anthropic" in module:
32
+ from ._anthropic import patch as _ap
33
+ _ap(client, endpoint)
34
+ elif "openai" in module:
35
+ from ._openai import patch as _op
36
+ _op(client, endpoint)
37
+
38
+
39
+ def _try_patch_anthropic_class(endpoint: str) -> None:
40
+ try:
41
+ import anthropic
42
+ _wrap_class_init(anthropic.Anthropic, endpoint, provider="anthropic")
43
+ except ImportError:
44
+ pass
45
+
46
+
47
+ def _try_patch_openai_class(endpoint: str) -> None:
48
+ try:
49
+ import openai
50
+ _wrap_class_init(openai.OpenAI, endpoint, provider="openai")
51
+ except ImportError:
52
+ pass
53
+
54
+
55
+ def _wrap_class_init(cls: type, endpoint: str, provider: str) -> None:
56
+ if getattr(cls, "_argus_patched", False):
57
+ return
58
+
59
+ original_init = cls.__init__
60
+
61
+ def __init__(self, *args, **kwargs):
62
+ original_init(self, *args, **kwargs)
63
+ if provider == "anthropic":
64
+ from ._anthropic import patch as _ap
65
+ _ap(self, endpoint)
66
+ else:
67
+ from ._openai import patch as _op
68
+ _op(self, endpoint)
69
+
70
+ cls.__init__ = __init__
71
+ cls._argus_patched = True
@@ -0,0 +1,31 @@
1
+ import datetime
2
+ import time
3
+
4
+ from ._reporter import report
5
+
6
+
7
+ def patch(client: object, endpoint: str) -> None:
8
+ """Wrap client.messages.create to capture signals after each response."""
9
+ messages = client.messages # type: ignore[attr-defined]
10
+ original_create = messages.create
11
+
12
+ def _create(*args, **kwargs):
13
+ t0 = time.monotonic()
14
+ response = original_create(*args, **kwargs)
15
+ latency_ms = int((time.monotonic() - t0) * 1000)
16
+ report(endpoint, {
17
+ "model": response.model,
18
+ "provider": "anthropic",
19
+ "input_tokens": response.usage.input_tokens,
20
+ "output_tokens": response.usage.output_tokens,
21
+ "latency_ms": latency_ms,
22
+ "finish_reason": response.stop_reason or "",
23
+ "timestamp_utc": _now(),
24
+ })
25
+ return response
26
+
27
+ messages.create = _create
28
+
29
+
30
+ def _now() -> str:
31
+ return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -0,0 +1,34 @@
1
+ import datetime
2
+ import time
3
+
4
+ from ._reporter import report
5
+
6
+
7
+ def patch(client: object, endpoint: str) -> None:
8
+ """Wrap client.chat.completions.create to capture signals after each response."""
9
+ completions = client.chat.completions # type: ignore[attr-defined]
10
+ original_create = completions.create
11
+
12
+ def _create(*args, **kwargs):
13
+ t0 = time.monotonic()
14
+ response = original_create(*args, **kwargs)
15
+ latency_ms = int((time.monotonic() - t0) * 1000)
16
+ finish_reason = ""
17
+ if response.choices:
18
+ finish_reason = response.choices[0].finish_reason or ""
19
+ report(endpoint, {
20
+ "model": response.model,
21
+ "provider": "openai",
22
+ "input_tokens": response.usage.prompt_tokens if response.usage else 0,
23
+ "output_tokens": response.usage.completion_tokens if response.usage else 0,
24
+ "latency_ms": latency_ms,
25
+ "finish_reason": finish_reason,
26
+ "timestamp_utc": _now(),
27
+ })
28
+ return response
29
+
30
+ completions.create = _create
31
+
32
+
33
+ def _now() -> str:
34
+ return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -0,0 +1,17 @@
1
+ import logging
2
+ import threading
3
+
4
+ logger = logging.getLogger("argus_sdk")
5
+
6
+
7
+ def _post(endpoint: str, event: dict) -> None:
8
+ try:
9
+ import httpx
10
+ with httpx.Client(timeout=3.0) as client:
11
+ client.post(f"{endpoint}/api/v1/events", json=event)
12
+ except Exception as exc:
13
+ logger.debug("argus: failed to report event: %s", exc)
14
+
15
+
16
+ def report(endpoint: str, event: dict) -> None:
17
+ threading.Thread(target=_post, args=(endpoint, event), daemon=True).start()
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "argus-sdk"
7
+ version = "0.1.0"
8
+ description = "Drop-in LLM behavioral drift detection"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "anthropic>=0.25",
12
+ "httpx>=0.27",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.23",
19
+ "ruff>=0.4",
20
+ ]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["argus_sdk"]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+ target-version = "py312"
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
File without changes
@@ -0,0 +1,323 @@
1
+ """Tests for argus_sdk patch() and the Anthropic/OpenAI wrappers."""
2
+ import re
3
+ import time
4
+ from unittest.mock import MagicMock, call, patch as mock_patch
5
+
6
+ import pytest
7
+
8
+ from argus_sdk import patch, _wrap_class_init
9
+ from argus_sdk._anthropic import patch as anthropic_patch
10
+ from argus_sdk._openai import patch as openai_patch
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def _anthropic_response(
17
+ model="claude-sonnet-4-6",
18
+ input_tokens=100,
19
+ output_tokens=50,
20
+ stop_reason="stop",
21
+ ):
22
+ resp = MagicMock()
23
+ resp.model = model
24
+ resp.usage.input_tokens = input_tokens
25
+ resp.usage.output_tokens = output_tokens
26
+ resp.stop_reason = stop_reason
27
+ return resp
28
+
29
+
30
+ def _openai_response(
31
+ model="gpt-4o",
32
+ prompt_tokens=100,
33
+ completion_tokens=50,
34
+ finish_reason="stop",
35
+ ):
36
+ resp = MagicMock()
37
+ resp.model = model
38
+ resp.usage.prompt_tokens = prompt_tokens
39
+ resp.usage.completion_tokens = completion_tokens
40
+ resp.choices = [MagicMock(finish_reason=finish_reason)]
41
+ return resp
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Anthropic — happy path
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def test_anthropic_patch_captures_event():
49
+ posted = []
50
+
51
+ client = MagicMock()
52
+ client.messages.create.return_value = _anthropic_response()
53
+
54
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
55
+ anthropic_patch(client, "http://localhost:4000")
56
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
57
+
58
+ assert len(posted) == 1
59
+ e = posted[0]
60
+ assert e["model"] == "claude-sonnet-4-6"
61
+ assert e["provider"] == "anthropic"
62
+ assert e["input_tokens"] == 100
63
+ assert e["output_tokens"] == 50
64
+ assert e["finish_reason"] == "stop"
65
+ assert e["latency_ms"] >= 0
66
+ assert e["timestamp_utc"].endswith("Z")
67
+
68
+
69
+ def test_anthropic_response_returned():
70
+ """patch() must not swallow the response — user code depends on it."""
71
+ client = MagicMock()
72
+ response = _anthropic_response(output_tokens=77)
73
+ client.messages.create.return_value = response
74
+
75
+ with mock_patch("argus_sdk._anthropic.report"):
76
+ anthropic_patch(client, "http://localhost:4000")
77
+ result = client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
78
+
79
+ assert result is response
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Anthropic — edge cases
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def test_anthropic_null_stop_reason_becomes_empty_string():
87
+ """stop_reason=None (e.g. mid-stream errors) must not produce null in the event."""
88
+ posted = []
89
+
90
+ client = MagicMock()
91
+ client.messages.create.return_value = _anthropic_response(stop_reason=None)
92
+
93
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
94
+ anthropic_patch(client, "http://localhost:4000")
95
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
96
+
97
+ assert posted[0]["finish_reason"] == ""
98
+
99
+
100
+ def test_anthropic_event_has_all_required_keys():
101
+ """The event payload must match the SDK integration contract exactly."""
102
+ REQUIRED = {"model", "provider", "input_tokens", "output_tokens", "latency_ms", "finish_reason", "timestamp_utc"}
103
+ posted = []
104
+
105
+ client = MagicMock()
106
+ client.messages.create.return_value = _anthropic_response()
107
+
108
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
109
+ anthropic_patch(client, "http://localhost:4000")
110
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
111
+
112
+ assert set(posted[0].keys()) == REQUIRED
113
+
114
+
115
+ def test_anthropic_timestamp_format():
116
+ """timestamp_utc must be ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ."""
117
+ posted = []
118
+
119
+ client = MagicMock()
120
+ client.messages.create.return_value = _anthropic_response()
121
+
122
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
123
+ anthropic_patch(client, "http://localhost:4000")
124
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
125
+
126
+ ts = posted[0]["timestamp_utc"]
127
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", ts), f"Bad timestamp: {ts!r}"
128
+
129
+
130
+ def test_anthropic_latency_is_measured():
131
+ """latency_ms must reflect actual wall-clock time of the underlying call."""
132
+ posted = []
133
+
134
+ client = MagicMock()
135
+
136
+ def slow_create(*args, **kwargs):
137
+ time.sleep(0.05)
138
+ return _anthropic_response()
139
+
140
+ client.messages.create.side_effect = slow_create
141
+
142
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
143
+ anthropic_patch(client, "http://localhost:4000")
144
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
145
+
146
+ assert posted[0]["latency_ms"] >= 40, "Expected at least 40 ms for a 50 ms sleep"
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # OpenAI — happy path
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def test_openai_patch_captures_event():
154
+ posted = []
155
+
156
+ client = MagicMock()
157
+ client.chat.completions.create.return_value = _openai_response()
158
+
159
+ with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
160
+ openai_patch(client, "http://localhost:4000")
161
+ client.chat.completions.create(model="gpt-4o", messages=[])
162
+
163
+ assert len(posted) == 1
164
+ e = posted[0]
165
+ assert e["model"] == "gpt-4o"
166
+ assert e["provider"] == "openai"
167
+ assert e["input_tokens"] == 100
168
+ assert e["output_tokens"] == 50
169
+ assert e["finish_reason"] == "stop"
170
+ assert e["latency_ms"] >= 0
171
+ assert e["timestamp_utc"].endswith("Z")
172
+
173
+
174
+ def test_openai_response_returned():
175
+ """OpenAI wrapper must not swallow the response."""
176
+ client = MagicMock()
177
+ response = _openai_response(completion_tokens=33)
178
+ client.chat.completions.create.return_value = response
179
+
180
+ with mock_patch("argus_sdk._openai.report"):
181
+ openai_patch(client, "http://localhost:4000")
182
+ result = client.chat.completions.create(model="gpt-4o", messages=[])
183
+
184
+ assert result is response
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # OpenAI — edge cases
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def test_openai_no_choices_gives_empty_finish_reason():
192
+ """If choices is empty (shouldn't happen, but guard it), finish_reason is ''."""
193
+ posted = []
194
+
195
+ client = MagicMock()
196
+ resp = MagicMock()
197
+ resp.model = "gpt-4o"
198
+ resp.choices = []
199
+ resp.usage.prompt_tokens = 10
200
+ resp.usage.completion_tokens = 5
201
+ client.chat.completions.create.return_value = resp
202
+
203
+ with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
204
+ openai_patch(client, "http://localhost:4000")
205
+ client.chat.completions.create(model="gpt-4o", messages=[])
206
+
207
+ assert posted[0]["finish_reason"] == ""
208
+
209
+
210
+ def test_openai_null_usage_gives_zero_tokens():
211
+ """Some streaming/mock responses omit usage; must not crash."""
212
+ posted = []
213
+
214
+ client = MagicMock()
215
+ resp = MagicMock()
216
+ resp.model = "gpt-4o"
217
+ resp.usage = None
218
+ resp.choices = [MagicMock(finish_reason="stop")]
219
+ client.chat.completions.create.return_value = resp
220
+
221
+ with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
222
+ openai_patch(client, "http://localhost:4000")
223
+ client.chat.completions.create(model="gpt-4o", messages=[])
224
+
225
+ assert posted[0]["input_tokens"] == 0
226
+ assert posted[0]["output_tokens"] == 0
227
+
228
+
229
+ def test_openai_event_has_all_required_keys():
230
+ REQUIRED = {"model", "provider", "input_tokens", "output_tokens", "latency_ms", "finish_reason", "timestamp_utc"}
231
+ posted = []
232
+
233
+ client = MagicMock()
234
+ client.chat.completions.create.return_value = _openai_response()
235
+
236
+ with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
237
+ openai_patch(client, "http://localhost:4000")
238
+ client.chat.completions.create(model="gpt-4o", messages=[])
239
+
240
+ assert set(posted[0].keys()) == REQUIRED
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # patch() — top-level API
245
+ # ---------------------------------------------------------------------------
246
+
247
+ def test_patch_noop_when_no_llm_library():
248
+ """patch() must not raise even if neither anthropic nor openai is installed."""
249
+ with mock_patch.dict("sys.modules", {"anthropic": None, "openai": None}):
250
+ patch(endpoint="http://localhost:4000")
251
+
252
+
253
+ def test_patch_with_explicit_anthropic_client():
254
+ """patch(client=...) should instrument a passed-in anthropic client directly."""
255
+ posted = []
256
+
257
+ class FakeAnthropicClient:
258
+ pass
259
+
260
+ FakeAnthropicClient.__module__ = "anthropic"
261
+ client = FakeAnthropicClient()
262
+ client.messages = MagicMock()
263
+ client.messages.create.return_value = _anthropic_response()
264
+
265
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
266
+ patch(endpoint="http://localhost:4000", client=client)
267
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
268
+
269
+ assert len(posted) == 1
270
+ assert posted[0]["provider"] == "anthropic"
271
+
272
+
273
+ def test_patch_with_explicit_openai_client():
274
+ """patch(client=...) should instrument a passed-in openai client directly."""
275
+ posted = []
276
+
277
+ class FakeOpenAIClient:
278
+ pass
279
+
280
+ FakeOpenAIClient.__module__ = "openai"
281
+ client = FakeOpenAIClient()
282
+ client.chat = MagicMock()
283
+ client.chat.completions.create.return_value = _openai_response()
284
+
285
+ with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
286
+ patch(endpoint="http://localhost:4000", client=client)
287
+ client.chat.completions.create(model="gpt-4o", messages=[])
288
+
289
+ assert len(posted) == 1
290
+ assert posted[0]["provider"] == "openai"
291
+
292
+
293
+ def test_class_level_patched_only_once():
294
+ """_wrap_class_init must be idempotent — calling it twice should not double-wrap."""
295
+ class FakeClient:
296
+ def __init__(self):
297
+ self.messages = MagicMock()
298
+ self.messages.create.return_value = _anthropic_response()
299
+
300
+ _wrap_class_init(FakeClient, "http://localhost:4000", provider="anthropic")
301
+ _wrap_class_init(FakeClient, "http://localhost:4000", provider="anthropic") # second call
302
+
303
+ assert FakeClient._argus_patched is True
304
+
305
+ posted = []
306
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
307
+ with mock_patch("argus_sdk._anthropic.patch") as mock_ap:
308
+ FakeClient()
309
+ assert mock_ap.call_count == 1 # wrapped once, not twice
310
+
311
+
312
+ def test_endpoint_passed_to_reporter():
313
+ """The endpoint given to patch() must be forwarded to report()."""
314
+ posted_endpoints = []
315
+
316
+ client = MagicMock()
317
+ client.messages.create.return_value = _anthropic_response()
318
+
319
+ with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted_endpoints.append(ep)):
320
+ anthropic_patch(client, "http://my-argus-server:4000")
321
+ client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
322
+
323
+ assert posted_endpoints[0] == "http://my-argus-server:4000"
@@ -0,0 +1,99 @@
1
+ """Tests for argus_sdk._reporter — background HTTP posting."""
2
+ import threading
3
+ from unittest.mock import MagicMock, patch as mock_patch
4
+
5
+ from argus_sdk._reporter import _post, report
6
+
7
+
8
+ _SAMPLE_EVENT = {
9
+ "model": "claude-sonnet-4-6",
10
+ "provider": "anthropic",
11
+ "input_tokens": 100,
12
+ "output_tokens": 50,
13
+ "latency_ms": 200,
14
+ "finish_reason": "stop",
15
+ "timestamp_utc": "2026-04-07T14:22:01Z",
16
+ }
17
+
18
+
19
+ def test_reporter_posts_to_correct_url():
20
+ """`_post` must call POST /api/v1/events on the given endpoint."""
21
+ mock_response = MagicMock()
22
+ mock_client_instance = MagicMock()
23
+ mock_client_instance.post.return_value = mock_response
24
+
25
+ with mock_patch("httpx.Client") as mock_httpx_client:
26
+ mock_httpx_client.return_value.__enter__.return_value = mock_client_instance
27
+ _post("http://localhost:4000", _SAMPLE_EVENT)
28
+
29
+ mock_client_instance.post.assert_called_once_with(
30
+ "http://localhost:4000/api/v1/events",
31
+ json=_SAMPLE_EVENT,
32
+ )
33
+
34
+
35
+ def test_reporter_swallows_connection_error():
36
+ """Network failures must never propagate to the caller."""
37
+ import httpx
38
+
39
+ with mock_patch("httpx.Client") as mock_httpx_client:
40
+ mock_httpx_client.return_value.__enter__.return_value.post.side_effect = (
41
+ httpx.ConnectError("Connection refused")
42
+ )
43
+ _post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
44
+
45
+
46
+ def test_reporter_swallows_timeout_error():
47
+ import httpx
48
+
49
+ with mock_patch("httpx.Client") as mock_httpx_client:
50
+ mock_httpx_client.return_value.__enter__.return_value.post.side_effect = (
51
+ httpx.TimeoutException("timed out")
52
+ )
53
+ _post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
54
+
55
+
56
+ def test_reporter_swallows_unexpected_exception():
57
+ with mock_patch("httpx.Client") as mock_httpx_client:
58
+ mock_httpx_client.return_value.__enter__.return_value.post.side_effect = RuntimeError("boom")
59
+ _post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
60
+
61
+
62
+ def test_report_fires_daemon_thread():
63
+ """`report()` must start a daemon thread so it never blocks process exit."""
64
+ threads_started = []
65
+
66
+ real_thread_init = threading.Thread.__init__
67
+
68
+ def capture_thread(self, *args, **kwargs):
69
+ real_thread_init(self, *args, **kwargs)
70
+ threads_started.append(self)
71
+
72
+ with mock_patch.object(threading.Thread, "__init__", capture_thread):
73
+ with mock_patch("argus_sdk._reporter._post"): # don't actually POST
74
+ report("http://localhost:4000", _SAMPLE_EVENT)
75
+
76
+ assert len(threads_started) == 1
77
+ assert threads_started[0].daemon is True
78
+
79
+
80
+ def test_report_does_not_block():
81
+ """`report()` must return before the HTTP call completes."""
82
+ import time
83
+
84
+ call_started = threading.Event()
85
+ call_done = threading.Event()
86
+
87
+ def slow_post(endpoint, event):
88
+ call_started.set()
89
+ time.sleep(0.1)
90
+ call_done.set()
91
+
92
+ with mock_patch("argus_sdk._reporter._post", side_effect=slow_post):
93
+ t0 = time.monotonic()
94
+ report("http://localhost:4000", _SAMPLE_EVENT)
95
+ elapsed = time.monotonic() - t0
96
+
97
+ # report() returns almost instantly; 100 ms sleep is in the background thread
98
+ assert elapsed < 0.05, f"report() blocked for {elapsed:.3f}s — expected < 0.05s"
99
+ call_started.wait(timeout=1.0) # background thread did fire