debugbundle-python 0.1.9__tar.gz → 1.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.
Files changed (42) hide show
  1. {debugbundle_python-0.1.9/src/debugbundle_python.egg-info → debugbundle_python-1.1.0}/PKG-INFO +2 -2
  2. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/pyproject.toml +2 -2
  3. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/config.py +65 -1
  4. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/core.py +64 -13
  5. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0/src/debugbundle_python.egg-info}/PKG-INFO +2 -2
  6. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle_python.egg-info/SOURCES.txt +1 -0
  7. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_remote_config.py +48 -2
  8. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_repository_metadata.py +2 -2
  9. debugbundle_python-1.1.0/tests/test_smoke_script.py +60 -0
  10. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/LICENSE +0 -0
  11. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/README.md +0 -0
  12. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/setup.cfg +0 -0
  13. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/__init__.py +0 -0
  14. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/__init__.py +0 -0
  15. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/common.py +0 -0
  16. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/django.py +0 -0
  17. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/fastapi.py +0 -0
  18. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/flask.py +0 -0
  19. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/relay_django.py +0 -0
  20. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/relay_fastapi.py +0 -0
  21. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/integrations/relay_flask.py +0 -0
  22. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/logger_integrations.py +0 -0
  23. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/py.typed +0 -0
  24. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/redaction.py +0 -0
  25. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/relay.py +0 -0
  26. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/relay_delivery.py +0 -0
  27. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/suppression.py +0 -0
  28. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/transport.py +0 -0
  29. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle/trigger_token.py +0 -0
  30. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  31. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle_python.egg-info/requires.txt +0 -0
  32. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  33. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_contracts.py +0 -0
  34. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_framework_integrations.py +0 -0
  35. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_hooks.py +0 -0
  36. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_http_integration.py +0 -0
  37. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_optional_imports.py +0 -0
  38. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_relay.py +0 -0
  39. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_relay_delivery.py +0 -0
  40. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_schema_validation.py +0 -0
  41. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_sdk.py +0 -0
  42. {debugbundle_python-0.1.9 → debugbundle_python-1.1.0}/tests/test_trigger_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.9
3
+ Version: 1.1.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://debugbundle.com/docs/sdks/python
8
8
  Project-URL: Repository, https://github.com/debugbundle/debugbundle-python
9
9
  Project-URL: Issues, https://github.com/debugbundle/debugbundle-python/issues
10
10
  Keywords: debugbundle,debugging,ai-agent,error-tracking
11
- Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Framework :: Django
13
13
  Classifier: Framework :: FastAPI
14
14
  Classifier: Framework :: Flask
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "0.1.9"
7
+ version = "1.1.0"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,7 +12,7 @@ license = "AGPL-3.0-only"
12
12
  authors = [{ name = "DebugBundle" }]
13
13
  keywords = ["debugbundle", "debugging", "ai-agent", "error-tracking"]
14
14
  classifiers = [
15
- "Development Status :: 3 - Alpha",
15
+ "Development Status :: 5 - Production/Stable",
16
16
  "Framework :: Django",
17
17
  "Framework :: FastAPI",
18
18
  "Framework :: Flask",
@@ -6,6 +6,13 @@ from typing import Any
6
6
  DEFAULT_PROBES_POLL_INTERVAL_MS = 60_000
7
7
 
8
8
 
9
+ @dataclass(frozen=True)
10
+ class ImmediateClientErrorPathRule:
11
+ status_code: int
12
+ path_pattern: str
13
+ methods: tuple[str, ...]
14
+
15
+
9
16
  @dataclass(frozen=True)
10
17
  class CapturePolicy:
11
18
  preset: str
@@ -14,6 +21,7 @@ class CapturePolicy:
14
21
  capture_breadcrumbs: str
15
22
  capture_probe_events: str
16
23
  immediate_client_error_statuses: tuple[int, ...]
24
+ immediate_client_error_path_rules: tuple[ImmediateClientErrorPathRule, ...] = ()
17
25
 
18
26
 
19
27
  @dataclass(frozen=True)
@@ -42,6 +50,7 @@ BALANCED_CAPTURE_POLICY = CapturePolicy(
42
50
  capture_breadcrumbs="exception_only",
43
51
  capture_probe_events="buffer_only",
44
52
  immediate_client_error_statuses=(),
53
+ immediate_client_error_path_rules=(),
45
54
  )
46
55
 
47
56
  MINIMAL_CAPTURE_POLICY = CapturePolicy(
@@ -51,6 +60,7 @@ MINIMAL_CAPTURE_POLICY = CapturePolicy(
51
60
  capture_breadcrumbs="local_only",
52
61
  capture_probe_events="buffer_only",
53
62
  immediate_client_error_statuses=(),
63
+ immediate_client_error_path_rules=(),
54
64
  )
55
65
 
56
66
 
@@ -123,6 +133,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
123
133
  immediate_client_error_statuses = _parse_immediate_client_error_statuses(
124
134
  payload.get("immediate_client_error_statuses")
125
135
  )
136
+ immediate_client_error_path_rules = _parse_immediate_client_error_path_rules(
137
+ payload.get("immediate_client_error_path_rules")
138
+ )
126
139
 
127
140
  if capture_logs not in {"off", "error", "warning", "info"}:
128
141
  return None
@@ -132,7 +145,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
132
145
  return None
133
146
  if capture_probe_events not in {"buffer_only", "standalone_when_activated"}:
134
147
  return None
135
- if immediate_client_error_statuses is None:
148
+ if immediate_client_error_statuses is None or immediate_client_error_path_rules is None:
136
149
  return None
137
150
 
138
151
  return CapturePolicy(
@@ -142,6 +155,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
142
155
  capture_breadcrumbs=capture_breadcrumbs,
143
156
  capture_probe_events=capture_probe_events,
144
157
  immediate_client_error_statuses=immediate_client_error_statuses,
158
+ immediate_client_error_path_rules=immediate_client_error_path_rules,
145
159
  )
146
160
 
147
161
 
@@ -160,6 +174,56 @@ def _parse_immediate_client_error_statuses(value: object) -> tuple[int, ...] | N
160
174
  return tuple(sorted(set(statuses)))
161
175
 
162
176
 
177
+ def _parse_immediate_client_error_path_rules(value: object) -> tuple[ImmediateClientErrorPathRule, ...] | None:
178
+ if value is None:
179
+ return ()
180
+ if not isinstance(value, list) or len(value) > 25:
181
+ return None
182
+
183
+ rules: list[ImmediateClientErrorPathRule] = []
184
+ for item in value:
185
+ if not isinstance(item, dict):
186
+ return None
187
+ status_code = item.get("status_code")
188
+ path_pattern = item.get("path_pattern")
189
+ raw_methods = item.get("methods", [])
190
+ if (
191
+ not isinstance(status_code, int)
192
+ or isinstance(status_code, bool)
193
+ or status_code < 400
194
+ or status_code > 499
195
+ or not isinstance(path_pattern, str)
196
+ or not _is_valid_path_pattern(path_pattern)
197
+ or not isinstance(raw_methods, list)
198
+ or len(raw_methods) > 7
199
+ ):
200
+ return None
201
+
202
+ methods: list[str] = []
203
+ for raw_method in raw_methods:
204
+ method = raw_method.upper() if isinstance(raw_method, str) else ""
205
+ if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
206
+ return None
207
+ if method not in methods:
208
+ methods.append(method)
209
+ rules.append(
210
+ ImmediateClientErrorPathRule(
211
+ status_code=status_code,
212
+ path_pattern=path_pattern,
213
+ methods=tuple(methods),
214
+ )
215
+ )
216
+
217
+ return tuple(rules)
218
+
219
+
220
+ def _is_valid_path_pattern(value: str) -> bool:
221
+ if not value.startswith("/") or len(value) == 0 or len(value) > 256 or "?" in value or "#" in value:
222
+ return False
223
+ wildcard_index = value.find("*")
224
+ return wildcard_index == -1 or wildcard_index == len(value) - 1
225
+
226
+
163
227
  def _parse_directive(payload: object) -> RemoteProbeDirective | None:
164
228
  if not isinstance(payload, dict):
165
229
  return None
@@ -55,9 +55,6 @@ LEVEL_RANKS = {
55
55
  }
56
56
  BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429}
57
57
  INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = BALANCED_IMMEDIATE_REQUEST_STATUSES | {409}
58
- BALANCED_STANDARD_ANOMALY_STATUSES = {401, 403, 404, 409, 422}
59
- BALANCED_HIGH_VOLUME_ANOMALY_STATUSES = {400, 410}
60
- INVESTIGATIVE_ANOMALY_STATUSES = BALANCED_STANDARD_ANOMALY_STATUSES | BALANCED_HIGH_VOLUME_ANOMALY_STATUSES
61
58
 
62
59
 
63
60
  @dataclass
@@ -286,7 +283,12 @@ class DebugBundleSdk:
286
283
  context: Mapping[str, object] | None = None,
287
284
  ) -> None:
288
285
  with self._lock:
289
- if not self._enabled or not self._passes_sample_rate() or not self._should_capture_request_event(response):
286
+ should_capture = (
287
+ self._enabled
288
+ and self._passes_sample_rate()
289
+ and self._should_capture_request_event(request, response)
290
+ )
291
+ if not should_capture:
290
292
  return
291
293
  payload = _request_event_payload(
292
294
  _redact_mapping(dict(request), self._redact_fields),
@@ -602,17 +604,31 @@ class DebugBundleSdk:
602
604
  policy_threshold = self._capture_policy.capture_logs
603
605
  return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
604
606
 
605
- def _should_capture_request_event(self, response: Mapping[str, object] | None) -> bool:
607
+ def _should_capture_request_event(
608
+ self,
609
+ request: Mapping[str, object] | None,
610
+ response: Mapping[str, object] | None,
611
+ ) -> bool:
606
612
  policy = self._capture_policy.capture_request_events
607
613
  status_code = None
608
614
  if response is not None:
609
615
  candidate = response.get("status_code") or response.get("response_status")
610
616
  if isinstance(candidate, int):
611
617
  status_code = candidate
618
+ request_path = None
619
+ http_method = None
620
+ if request is not None:
621
+ path_candidate = request.get("path") or request.get("url")
622
+ method_candidate = request.get("method")
623
+ request_path = path_candidate if isinstance(path_candidate, str) else None
624
+ http_method = method_candidate if isinstance(method_candidate, str) else None
612
625
  if _is_immediate_request_incident_status(
613
626
  status_code,
614
627
  self._capture_policy.preset,
615
628
  self._capture_policy.immediate_client_error_statuses,
629
+ request_path,
630
+ http_method,
631
+ self._capture_policy.immediate_client_error_path_rules,
616
632
  ):
617
633
  return True
618
634
  if policy == "off":
@@ -624,7 +640,7 @@ class DebugBundleSdk:
624
640
  if status_code is None:
625
641
  return policy == "filtered"
626
642
  if policy == "failures_only":
627
- return _is_request_anomaly_candidate_status(status_code, self._capture_policy.preset)
643
+ return status_code >= 500
628
644
  if policy == "filtered":
629
645
  return False
630
646
  return True
@@ -859,6 +875,9 @@ def _is_immediate_request_incident_status(
859
875
  status_code: int | None,
860
876
  preset: str,
861
877
  immediate_client_error_statuses: tuple[int, ...],
878
+ request_path: str | None = None,
879
+ http_method: str | None = None,
880
+ immediate_client_error_path_rules: tuple[object, ...] = (),
862
881
  ) -> bool:
863
882
  if status_code is None:
864
883
  return False
@@ -866,6 +885,13 @@ def _is_immediate_request_incident_status(
866
885
  return True
867
886
  if status_code in immediate_client_error_statuses:
868
887
  return True
888
+ if _matches_immediate_client_error_path_rule(
889
+ status_code,
890
+ request_path,
891
+ http_method,
892
+ immediate_client_error_path_rules,
893
+ ):
894
+ return True
869
895
  if preset == "investigative":
870
896
  return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
871
897
  if preset == "balanced":
@@ -873,16 +899,41 @@ def _is_immediate_request_incident_status(
873
899
  return False
874
900
 
875
901
 
876
- def _is_request_anomaly_candidate_status(status_code: int | None, preset: str) -> bool:
877
- if status_code is None or status_code < 400 or status_code >= 500:
902
+ def _matches_immediate_client_error_path_rule(
903
+ status_code: int,
904
+ request_path: str | None,
905
+ http_method: str | None,
906
+ rules: tuple[object, ...],
907
+ ) -> bool:
908
+ if status_code < 400 or status_code > 499 or request_path is None:
878
909
  return False
879
- if preset == "investigative":
880
- return status_code in INVESTIGATIVE_ANOMALY_STATUSES
881
- if preset == "balanced":
882
- return status_code in BALANCED_STANDARD_ANOMALY_STATUSES or status_code in BALANCED_HIGH_VOLUME_ANOMALY_STATUSES
910
+ normalized_path = _normalize_request_path(request_path)
911
+ normalized_method = http_method.upper() if isinstance(http_method, str) else None
912
+ for rule in rules:
913
+ rule_status = getattr(rule, "status_code", None)
914
+ path_pattern = getattr(rule, "path_pattern", None)
915
+ methods = getattr(rule, "methods", ())
916
+ if rule_status != status_code or not isinstance(path_pattern, str):
917
+ continue
918
+ if methods and (normalized_method is None or normalized_method not in methods):
919
+ continue
920
+ if path_pattern.endswith("*"):
921
+ if normalized_path.startswith(path_pattern[:-1]):
922
+ return True
923
+ elif normalized_path == path_pattern:
924
+ return True
883
925
  return False
884
926
 
885
927
 
928
+ def _normalize_request_path(value: str) -> str:
929
+ from urllib.parse import urlparse
930
+
931
+ parsed = urlparse(value)
932
+ if parsed.path:
933
+ return parsed.path
934
+ return value.split("?", 1)[0].split("#", 1)[0] if value.startswith("/") else "/"
935
+
936
+
886
937
  def _time_now() -> float:
887
938
  return datetime.now(tz=timezone.utc).timestamp()
888
939
 
@@ -891,7 +942,7 @@ def _sdk_version() -> str:
891
942
  try:
892
943
  return metadata.version("debugbundle-python")
893
944
  except metadata.PackageNotFoundError:
894
- return "0.1.0"
945
+ return "1.1.0"
895
946
 
896
947
 
897
948
  def _sdk_config_endpoint(events_endpoint: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.9
3
+ Version: 1.1.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://debugbundle.com/docs/sdks/python
8
8
  Project-URL: Repository, https://github.com/debugbundle/debugbundle-python
9
9
  Project-URL: Issues, https://github.com/debugbundle/debugbundle-python/issues
10
10
  Keywords: debugbundle,debugging,ai-agent,error-tracking
11
- Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Framework :: Django
13
13
  Classifier: Framework :: FastAPI
14
14
  Classifier: Framework :: Flask
@@ -36,4 +36,5 @@ tests/test_remote_config.py
36
36
  tests/test_repository_metadata.py
37
37
  tests/test_schema_validation.py
38
38
  tests/test_sdk.py
39
+ tests/test_smoke_script.py
39
40
  tests/test_trigger_token.py
@@ -229,7 +229,7 @@ def test_capture_policy_filters_logs_and_request_events_from_remote_config() ->
229
229
  assert events[1]["payload"]["response_status"] == 503
230
230
 
231
231
 
232
- def test_balanced_capture_policy_keeps_request_failure_anomaly_candidates() -> None:
232
+ def test_balanced_capture_policy_keeps_immediate_failures_but_not_unconfigured_4xx() -> None:
233
233
  clock = ManualClock()
234
234
  fetch = FakeFetch(
235
235
  responses=[
@@ -271,7 +271,53 @@ def test_balanced_capture_policy_keeps_request_failure_anomaly_candidates() -> N
271
271
  for event in events
272
272
  if event["event_type"] == "request_event"
273
273
  ]
274
- assert request_statuses == [429, 404, 409]
274
+ assert request_statuses == [429]
275
+
276
+
277
+ def test_capture_policy_promotes_configured_client_error_path_rules_when_request_capture_is_off() -> None:
278
+ clock = ManualClock()
279
+ fetch = FakeFetch(
280
+ responses=[
281
+ FakeConfigResponse(
282
+ 200,
283
+ {
284
+ "probes_enabled": True,
285
+ "remote_probes_enabled": True,
286
+ "active_probes": [],
287
+ "poll_interval_ms": 15000,
288
+ "capture_policy": {
289
+ "preset": "minimal",
290
+ "capture_logs": "error",
291
+ "capture_request_events": "off",
292
+ "capture_breadcrumbs": "local_only",
293
+ "capture_probe_events": "buffer_only",
294
+ "immediate_client_error_path_rules": [
295
+ {"status_code": 404, "path_pattern": "/checkout/*", "methods": ["POST"]}
296
+ ],
297
+ },
298
+ },
299
+ )
300
+ ]
301
+ )
302
+ transport = FakeTransport()
303
+ sdk = DebugBundleSdk(transport=transport, time_provider=clock.time)
304
+ sdk.init(
305
+ project_token="dbundle_proj_test",
306
+ service="checkout-api",
307
+ environment="production",
308
+ fetch_impl=fetch,
309
+ )
310
+
311
+ sdk.capture_request({"method": "POST", "path": "/checkout/cart", "headers": {}}, {"status_code": 404})
312
+ sdk.capture_request({"method": "GET", "path": "/checkout/cart", "headers": {}}, {"status_code": 404})
313
+ sdk.capture_request({"method": "POST", "path": "/robots.txt", "headers": {}}, {"status_code": 404})
314
+ sdk.flush()
315
+
316
+ events = _events(transport)
317
+ request_events = [event for event in events if event["event_type"] == "request_event"]
318
+ assert len(request_events) == 1
319
+ assert request_events[0]["payload"]["path"] == "/checkout/cart"
320
+ assert request_events[0]["payload"]["response_status"] == 404
275
321
 
276
322
 
277
323
  def test_investigative_capture_policy_promotes_409_even_when_request_capture_is_off() -> None:
@@ -30,14 +30,14 @@ def test_standalone_changelog_and_security_policy_are_launch_ready() -> None:
30
30
  security = (REPO_ROOT / "SECURITY.md").read_text(encoding="utf-8")
31
31
 
32
32
  assert "## [Unreleased]" in changelog
33
- assert "## [0.1.0] - 2026-05-07" in changelog
33
+ assert "## [1.1.0] - 2026-06-08" in changelog
34
34
  assert "https://github.com/debugbundle/debugbundle-python/security/advisories/new" in security
35
35
 
36
36
 
37
37
  def test_standalone_ci_workflow_covers_python_sdk_checks() -> None:
38
38
  workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
39
39
 
40
- assert "actions/setup-python@v5" in workflow
40
+ assert "actions/setup-python@v6" in workflow
41
41
  assert 'python-version: "3.12"' in workflow
42
42
  assert "python -m pip install -e .[dev]" in workflow
43
43
  assert "ruff check src tests" in workflow
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ REPO_ROOT = Path(__file__).resolve().parents[1]
9
+ SMOKE_SCRIPT = REPO_ROOT / "smoke" / "run_app_driven_smoke.py"
10
+ SPEC = importlib.util.spec_from_file_location("debugbundle_python_smoke", SMOKE_SCRIPT)
11
+ assert SPEC is not None and SPEC.loader is not None
12
+ SMOKE = importlib.util.module_from_spec(SPEC)
13
+ sys.modules.setdefault("debugbundle_python_smoke", SMOKE)
14
+ SPEC.loader.exec_module(SMOKE)
15
+
16
+
17
+ def test_install_with_retry_retries_until_success(monkeypatch) -> None:
18
+ calls: list[list[str]] = []
19
+ sleeps: list[int] = []
20
+ outcomes = [
21
+ subprocess.CalledProcessError(returncode=1, cmd=["pip", "install"]),
22
+ None,
23
+ ]
24
+
25
+ def fake_run_subprocess(command: list[str]) -> None:
26
+ calls.append(command)
27
+ outcome = outcomes.pop(0)
28
+ if outcome is not None:
29
+ raise outcome
30
+
31
+ monkeypatch.setattr(SMOKE, "_run_subprocess", fake_run_subprocess)
32
+ monkeypatch.setattr(SMOKE.time, "sleep", sleeps.append)
33
+
34
+ SMOKE._install_with_retry(
35
+ ["python", "-m", "pip", "install", "debugbundle-python==1.1.0"],
36
+ retries=3,
37
+ retry_delay_seconds=7,
38
+ )
39
+
40
+ assert len(calls) == 2
41
+ assert sleeps == [7]
42
+
43
+
44
+ def test_install_with_retry_raises_after_final_attempt(monkeypatch) -> None:
45
+ def fake_run_subprocess(command: list[str]) -> None:
46
+ raise subprocess.CalledProcessError(returncode=1, cmd=command)
47
+
48
+ monkeypatch.setattr(SMOKE, "_run_subprocess", fake_run_subprocess)
49
+ monkeypatch.setattr(SMOKE.time, "sleep", lambda seconds: None)
50
+
51
+ try:
52
+ SMOKE._install_with_retry(
53
+ ["python", "-m", "pip", "install", "debugbundle-python==1.1.0"],
54
+ retries=2,
55
+ retry_delay_seconds=1,
56
+ )
57
+ except subprocess.CalledProcessError as error:
58
+ assert error.returncode == 1
59
+ else:
60
+ raise AssertionError("Expected install retry helper to re-raise after the final attempt.")