debugbundle-python 0.1.8__tar.gz → 1.0.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.8/src/debugbundle_python.egg-info → debugbundle_python-1.0.0}/PKG-INFO +2 -2
  2. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/pyproject.toml +2 -2
  3. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/core.py +1 -1
  4. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/relay_django.py +7 -2
  5. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/relay_fastapi.py +3 -3
  6. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/relay_flask.py +2 -1
  7. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/relay.py +39 -19
  8. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0/src/debugbundle_python.egg-info}/PKG-INFO +2 -2
  9. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle_python.egg-info/SOURCES.txt +1 -0
  10. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_relay.py +23 -0
  11. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_repository_metadata.py +1 -1
  12. debugbundle_python-1.0.0/tests/test_smoke_script.py +60 -0
  13. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/LICENSE +0 -0
  14. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/README.md +0 -0
  15. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/setup.cfg +0 -0
  16. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/__init__.py +0 -0
  17. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/config.py +0 -0
  18. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/__init__.py +0 -0
  19. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/common.py +0 -0
  20. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/django.py +0 -0
  21. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/fastapi.py +0 -0
  22. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/integrations/flask.py +0 -0
  23. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/logger_integrations.py +0 -0
  24. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/py.typed +0 -0
  25. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/redaction.py +0 -0
  26. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/relay_delivery.py +0 -0
  27. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/suppression.py +0 -0
  28. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/transport.py +0 -0
  29. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle/trigger_token.py +0 -0
  30. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  31. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle_python.egg-info/requires.txt +0 -0
  32. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  33. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_contracts.py +0 -0
  34. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_framework_integrations.py +0 -0
  35. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_hooks.py +0 -0
  36. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_http_integration.py +0 -0
  37. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_optional_imports.py +0 -0
  38. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_relay_delivery.py +0 -0
  39. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_remote_config.py +0 -0
  40. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_schema_validation.py +0 -0
  41. {debugbundle_python-0.1.8 → debugbundle_python-1.0.0}/tests/test_sdk.py +0 -0
  42. {debugbundle_python-0.1.8 → debugbundle_python-1.0.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.8
3
+ Version: 1.0.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.8"
7
+ version = "1.0.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",
@@ -891,7 +891,7 @@ def _sdk_version() -> str:
891
891
  try:
892
892
  return metadata.version("debugbundle-python")
893
893
  except metadata.PackageNotFoundError:
894
- return "0.1.0"
894
+ return "1.0.0"
895
895
 
896
896
 
897
897
  def _sdk_config_endpoint(events_endpoint: str) -> str:
@@ -54,9 +54,14 @@ def create_django_relay_view(
54
54
  )
55
55
 
56
56
  if response.body is not None:
57
- return JsonResponse(response.body, status=response.status, safe=False)
57
+ result = JsonResponse(response.body, status=response.status, safe=False)
58
+ else:
59
+ result = JsonResponse({}, status=response.status)
58
60
 
59
- return JsonResponse({}, status=response.status)
61
+ for key, value in response.headers.items():
62
+ result[key] = value
63
+
64
+ return result
60
65
 
61
66
  return view
62
67
 
@@ -42,7 +42,7 @@ def create_fastapi_relay_handler(
42
42
  )
43
43
 
44
44
  def register(app: Any) -> None:
45
- @app.post(route_path)
45
+ @app.api_route(route_path, methods=["POST", "OPTIONS"])
46
46
  async def debugbundle_browser_relay(request: Request) -> Response:
47
47
  body = await request.body()
48
48
  headers = {str(key): str(value) for key, value in request.headers.items()}
@@ -58,7 +58,7 @@ def create_fastapi_relay_handler(
58
58
  )
59
59
 
60
60
  if response.body is not None:
61
- return JSONResponse(content=response.body, status_code=response.status)
62
- return Response(status_code=response.status)
61
+ return JSONResponse(content=response.body, status_code=response.status, headers=response.headers)
62
+ return Response(status_code=response.status, headers=response.headers)
63
63
 
64
64
  return register
@@ -41,7 +41,7 @@ def create_flask_relay_handler(
41
41
  def register(app: Any) -> None:
42
42
  from flask import Response, request
43
43
 
44
- @app.route(route_path, methods=["POST"])
44
+ @app.route(route_path, methods=["POST", "OPTIONS"])
45
45
  def debugbundle_browser_relay() -> Response:
46
46
  response = handler.handle(
47
47
  {
@@ -58,6 +58,7 @@ def create_flask_relay_handler(
58
58
  return Response(
59
59
  body,
60
60
  status=response.status,
61
+ headers=response.headers,
61
62
  content_type="application/json",
62
63
  )
63
64
 
@@ -33,6 +33,7 @@ ACCEPTED_EVENT_TYPES = frozenset(
33
33
  class BrowserRelayResponse:
34
34
  status: int
35
35
  body: dict[str, Any] | None = None
36
+ headers: dict[str, str] = field(default_factory=dict)
36
37
 
37
38
 
38
39
  @dataclass(frozen=True)
@@ -78,47 +79,56 @@ class BrowserRelayHandler:
78
79
 
79
80
  def handle(self, request: dict[str, Any]) -> BrowserRelayResponse:
80
81
  method = str(request.get("method", "POST")).upper()
81
- if method != "POST":
82
- return BrowserRelayResponse(405)
83
-
84
82
  headers = _normalize_headers(request.get("headers") or {})
83
+ source_origin = _source_origin(headers)
85
84
  if not self._is_origin_allowed(headers):
86
85
  return BrowserRelayResponse(403)
87
86
 
87
+ response_headers = _cors_headers(source_origin) if source_origin else {}
88
+
89
+ def with_headers(response: BrowserRelayResponse) -> BrowserRelayResponse:
90
+ return BrowserRelayResponse(response.status, response.body, {**response_headers, **response.headers})
91
+
92
+ if method == "OPTIONS":
93
+ return with_headers(BrowserRelayResponse(204))
94
+
95
+ if method != "POST":
96
+ return with_headers(BrowserRelayResponse(405))
97
+
88
98
  if not _is_supported_content_type(headers.get("content-type")):
89
- return BrowserRelayResponse(
99
+ return with_headers(BrowserRelayResponse(
90
100
  400,
91
101
  {"accepted": 0, "rejected": 0, "errors": ["Relay requests must use Content-Type: application/json."]},
92
- )
102
+ ))
93
103
 
94
104
  body: str = request.get("body", "")
95
105
  if len(body.encode("utf-8") if isinstance(body, str) else body) > self.max_body_bytes:
96
- return BrowserRelayResponse(413)
106
+ return with_headers(BrowserRelayResponse(413))
97
107
 
98
108
  ip_address: str | None = request.get("ipAddress") or request.get("ip_address")
99
109
  if self._is_rate_limited(ip_address):
100
- return BrowserRelayResponse(429)
110
+ return with_headers(BrowserRelayResponse(429))
101
111
 
102
112
  try:
103
113
  decoded = json.loads(body)
104
114
  except (json.JSONDecodeError, TypeError, ValueError):
105
- return BrowserRelayResponse(
115
+ return with_headers(BrowserRelayResponse(
106
116
  400,
107
117
  {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
108
- )
118
+ ))
109
119
 
110
120
  if not isinstance(decoded, dict):
111
- return BrowserRelayResponse(
121
+ return with_headers(BrowserRelayResponse(
112
122
  400,
113
123
  {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
114
- )
124
+ ))
115
125
 
116
126
  batch = decoded.get("batch")
117
127
  if not isinstance(batch, list):
118
- return BrowserRelayResponse(
128
+ return with_headers(BrowserRelayResponse(
119
129
  400,
120
130
  {"accepted": 0, "rejected": 0, "errors": ["Relay request body must include a batch array."]},
121
- )
131
+ ))
122
132
 
123
133
  accepted_events: list[dict[str, Any]] = []
124
134
  errors: list[str] = []
@@ -144,7 +154,7 @@ class BrowserRelayHandler:
144
154
  if accepted_events:
145
155
  try:
146
156
  if not self._deliver_events(accepted_events):
147
- return BrowserRelayResponse(500)
157
+ return with_headers(BrowserRelayResponse(500))
148
158
 
149
159
  if self.on_accept is not None:
150
160
  self.on_accept(
@@ -156,18 +166,18 @@ class BrowserRelayHandler:
156
166
  )
157
167
  )
158
168
  except Exception:
159
- return BrowserRelayResponse(500)
169
+ return with_headers(BrowserRelayResponse(500))
160
170
 
161
171
  if errors:
162
- return BrowserRelayResponse(
172
+ return with_headers(BrowserRelayResponse(
163
173
  400,
164
174
  {"accepted": len(accepted_events), "rejected": len(errors), "errors": errors},
165
- )
175
+ ))
166
176
 
167
- return BrowserRelayResponse(
177
+ return with_headers(BrowserRelayResponse(
168
178
  202,
169
179
  {"accepted": len(accepted_events), "rejected": 0, "errors": []},
170
- )
180
+ ))
171
181
 
172
182
  def _deliver_events(self, accepted_events: list[dict[str, Any]]) -> bool:
173
183
  if self.project_mode is None:
@@ -261,6 +271,16 @@ def _is_supported_content_type(content_type: str | None) -> bool:
261
271
  return isinstance(content_type, str) and "application/json" in content_type.lower()
262
272
 
263
273
 
274
+ def _cors_headers(origin: str) -> dict[str, str]:
275
+ return {
276
+ "access-control-allow-origin": origin,
277
+ "access-control-allow-methods": "POST, OPTIONS",
278
+ "access-control-allow-headers": "content-type",
279
+ "access-control-max-age": "600",
280
+ "vary": "Origin",
281
+ }
282
+
283
+
264
284
  def _source_origin(headers: dict[str, str]) -> str | None:
265
285
  origin = (headers.get("origin") or "").strip()
266
286
  if origin:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.8
3
+ Version: 1.0.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
@@ -78,6 +78,29 @@ def test_rejects_non_post() -> None:
78
78
  assert response.status == 405
79
79
 
80
80
 
81
+ def test_answers_allowed_cross_origin_preflight() -> None:
82
+ handler = BrowserRelayHandler(allowed_origins=["https://web.example.com"])
83
+ response = handler.handle(
84
+ _make_request(
85
+ body="",
86
+ method="OPTIONS",
87
+ origin="https://web.example.com",
88
+ host="api.example.com",
89
+ )
90
+ )
91
+ assert response.status == 204
92
+ assert response.headers["access-control-allow-origin"] == "https://web.example.com"
93
+ assert response.headers["access-control-allow-methods"] == "POST, OPTIONS"
94
+
95
+
96
+ def test_adds_cors_headers_to_accepted_cross_origin_posts() -> None:
97
+ handler = BrowserRelayHandler(allowed_origins=["https://web.example.com"])
98
+ response = handler.handle(_make_request(origin="https://web.example.com", host="api.example.com"))
99
+ assert response.status == 202
100
+ assert response.headers["access-control-allow-origin"] == "https://web.example.com"
101
+ assert response.headers["vary"] == "Origin"
102
+
103
+
81
104
  def test_rejects_invalid_origin() -> None:
82
105
  handler = BrowserRelayHandler(allowed_origins=["https://allowed.com"])
83
106
  response = handler.handle(_make_request(origin="https://attacker.com"))
@@ -30,7 +30,7 @@ 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.0.0] - 2026-05-31" in changelog
34
34
  assert "https://github.com/debugbundle/debugbundle-python/security/advisories/new" in security
35
35
 
36
36
 
@@ -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.0.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.0.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.")