smello 0.1.1__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,16 @@
1
+ [tool.bumpversion]
2
+ current_version = "0.1.1"
3
+ commit = true
4
+ tag = true
5
+ tag_name = "smello/v{new_version}"
6
+ tag_message = "Release smello v{new_version}"
7
+ message = "Bump smello version: {current_version} → {new_version}"
8
+
9
+ [[tool.bumpversion.files]]
10
+ filename = "clients/python/pyproject.toml"
11
+ key_path = "project.version"
12
+
13
+ [[tool.bumpversion.files]]
14
+ filename = "clients/python/src/smello/__init__.py"
15
+ search = '__version__ = "{current_version}"'
16
+ replace = '__version__ = "{new_version}"'
@@ -0,0 +1,23 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+ .eggs/
9
+ *.so
10
+ .venv/
11
+ venv/
12
+ env/
13
+ .env
14
+ *.db
15
+ *.sqlite3
16
+ .mypy_cache/
17
+ .pytest_cache/
18
+ .ruff_cache/
19
+ .coverage
20
+ htmlcov/
21
+ *.log
22
+ .DS_Store
23
+ Thumbs.db
smello-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: smello
3
+ Version: 0.1.1
4
+ Summary: Capture outgoing HTTP requests and inspect them in a local web dashboard
5
+ Project-URL: Homepage, https://github.com/smelloscope/smello
6
+ Project-URL: Repository, https://github.com/smelloscope/smello
7
+ Project-URL: Issues, https://github.com/smelloscope/smello/issues
8
+ Author-email: Roman Imankulov <roman.imankulov@gmail.com>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Topic :: System :: Networking :: Monitoring
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Smello
25
+
26
+ Capture outgoing HTTP requests from your Python code and browse them in a local web dashboard.
27
+
28
+ Like [Mailpit](https://mailpit.axllent.org/), but for HTTP requests.
29
+
30
+ ## Setup
31
+
32
+ Install the client SDK and the server:
33
+
34
+ ```bash
35
+ pip install smello smello-server
36
+ ```
37
+
38
+ Start the server:
39
+
40
+ ```bash
41
+ smello-server run
42
+ ```
43
+
44
+ Add two lines to your code:
45
+
46
+ ```python
47
+ import smello
48
+ smello.init()
49
+
50
+ import requests
51
+ resp = requests.get("https://api.stripe.com/v1/charges")
52
+
53
+ # Browse captured requests at http://localhost:5110
54
+ ```
55
+
56
+ Smello monkey-patches `requests` and `httpx` to capture all outgoing HTTP traffic. Browse results at `http://localhost:5110`.
57
+
58
+ ## What Smello Captures
59
+
60
+ - Method, URL, headers, and body
61
+ - Response status code, headers, and body
62
+ - Duration in milliseconds
63
+ - HTTP library used (requests or httpx)
64
+
65
+ Smello redacts sensitive headers (`Authorization`, `X-Api-Key`) by default.
66
+
67
+ ## Configuration
68
+
69
+ ```python
70
+ smello.init(
71
+ server_url="http://localhost:5110", # where to send captured data
72
+ capture_hosts=["api.stripe.com"], # only capture these hosts
73
+ capture_all=True, # capture everything (default)
74
+ ignore_hosts=["localhost"], # skip these hosts
75
+ redact_headers=["Authorization"], # replace values with [REDACTED]
76
+ enabled=True, # kill switch
77
+ )
78
+ ```
79
+
80
+ ## Supported Libraries
81
+
82
+ - **requests** — patches `Session.send()`
83
+ - **httpx** — patches `Client.send()` and `AsyncClient.send()`
84
+
85
+ ## Requires
86
+
87
+ - Python >= 3.10
88
+ - [smello-server](https://pypi.org/project/smello-server/) running locally
89
+
90
+ ## Links
91
+
92
+ - [Documentation & Source](https://github.com/smelloscope/smello)
93
+ - [smello-server on PyPI](https://pypi.org/project/smello-server/)
smello-0.1.1/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # Smello
2
+
3
+ Capture outgoing HTTP requests from your Python code and browse them in a local web dashboard.
4
+
5
+ Like [Mailpit](https://mailpit.axllent.org/), but for HTTP requests.
6
+
7
+ ## Setup
8
+
9
+ Install the client SDK and the server:
10
+
11
+ ```bash
12
+ pip install smello smello-server
13
+ ```
14
+
15
+ Start the server:
16
+
17
+ ```bash
18
+ smello-server run
19
+ ```
20
+
21
+ Add two lines to your code:
22
+
23
+ ```python
24
+ import smello
25
+ smello.init()
26
+
27
+ import requests
28
+ resp = requests.get("https://api.stripe.com/v1/charges")
29
+
30
+ # Browse captured requests at http://localhost:5110
31
+ ```
32
+
33
+ Smello monkey-patches `requests` and `httpx` to capture all outgoing HTTP traffic. Browse results at `http://localhost:5110`.
34
+
35
+ ## What Smello Captures
36
+
37
+ - Method, URL, headers, and body
38
+ - Response status code, headers, and body
39
+ - Duration in milliseconds
40
+ - HTTP library used (requests or httpx)
41
+
42
+ Smello redacts sensitive headers (`Authorization`, `X-Api-Key`) by default.
43
+
44
+ ## Configuration
45
+
46
+ ```python
47
+ smello.init(
48
+ server_url="http://localhost:5110", # where to send captured data
49
+ capture_hosts=["api.stripe.com"], # only capture these hosts
50
+ capture_all=True, # capture everything (default)
51
+ ignore_hosts=["localhost"], # skip these hosts
52
+ redact_headers=["Authorization"], # replace values with [REDACTED]
53
+ enabled=True, # kill switch
54
+ )
55
+ ```
56
+
57
+ ## Supported Libraries
58
+
59
+ - **requests** — patches `Session.send()`
60
+ - **httpx** — patches `Client.send()` and `AsyncClient.send()`
61
+
62
+ ## Requires
63
+
64
+ - Python >= 3.10
65
+ - [smello-server](https://pypi.org/project/smello-server/) running locally
66
+
67
+ ## Links
68
+
69
+ - [Documentation & Source](https://github.com/smelloscope/smello)
70
+ - [smello-server on PyPI](https://pypi.org/project/smello-server/)
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "smello"
3
+ version = "0.1.1"
4
+ description = "Capture outgoing HTTP requests and inspect them in a local web dashboard"
5
+ license = "MIT"
6
+ requires-python = ">=3.10"
7
+ readme = "README.md"
8
+ authors = [{ name = "Roman Imankulov", email = "roman.imankulov@gmail.com" }]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Software Development :: Testing",
20
+ "Topic :: System :: Networking :: Monitoring",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/smelloscope/smello"
26
+ Repository = "https://github.com/smelloscope/smello"
27
+ Issues = "https://github.com/smelloscope/smello/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/smello"]
@@ -0,0 +1,49 @@
1
+ """Smello - Capture outgoing HTTP requests automatically."""
2
+
3
+ from smello.config import SmelloConfig
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ _config: SmelloConfig | None = None
8
+
9
+
10
+ def init(
11
+ server_url: str = "http://localhost:5110",
12
+ capture_hosts: list[str] | None = None,
13
+ capture_all: bool = True,
14
+ ignore_hosts: list[str] | None = None,
15
+ redact_headers: list[str] | None = None,
16
+ enabled: bool = True,
17
+ ) -> None:
18
+ """Initialize Smello. Patches requests and httpx to capture outgoing HTTP traffic."""
19
+ global _config
20
+
21
+ if not enabled:
22
+ return
23
+
24
+ _config = SmelloConfig(
25
+ server_url=server_url.rstrip("/"),
26
+ capture_hosts=capture_hosts or [],
27
+ capture_all=capture_all,
28
+ ignore_hosts=ignore_hosts or [],
29
+ redact_headers=[
30
+ h.lower() for h in (redact_headers or ["authorization", "x-api-key"])
31
+ ],
32
+ )
33
+
34
+ # Always ignore the smello server itself
35
+ from urllib.parse import urlparse
36
+
37
+ server_host = urlparse(_config.server_url).hostname
38
+ if server_host and server_host not in _config.ignore_hosts:
39
+ _config.ignore_hosts.append(server_host)
40
+
41
+ # Start transport worker
42
+ from smello.transport import start_worker
43
+
44
+ start_worker(_config.server_url)
45
+
46
+ # Apply patches
47
+ from smello.patches import apply_all
48
+
49
+ apply_all(_config)
@@ -0,0 +1,73 @@
1
+ """Serialize captured HTTP request/response pairs for sending to the server."""
2
+
3
+ import time
4
+ import uuid
5
+
6
+ from smello.config import SmelloConfig
7
+
8
+
9
+ def serialize_request_response(
10
+ config: SmelloConfig,
11
+ method: str,
12
+ url: str,
13
+ request_headers: dict,
14
+ request_body: str | bytes | None,
15
+ status_code: int,
16
+ response_headers: dict,
17
+ response_body: str | bytes | None,
18
+ duration_s: float,
19
+ library: str,
20
+ ) -> dict:
21
+ """Build the capture payload dict."""
22
+ req_headers = _redact_headers(dict(request_headers), config.redact_headers)
23
+ resp_headers = dict(response_headers)
24
+
25
+ req_body_str = _body_to_str(request_body)
26
+ resp_body_str = _body_to_str(response_body)
27
+
28
+ return {
29
+ "id": str(uuid.uuid4()),
30
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
31
+ "duration_ms": int(duration_s * 1000),
32
+ "request": {
33
+ "method": method,
34
+ "url": url,
35
+ "headers": req_headers,
36
+ "body": req_body_str,
37
+ "body_size": len(request_body) if request_body else 0,
38
+ },
39
+ "response": {
40
+ "status_code": status_code,
41
+ "headers": resp_headers,
42
+ "body": resp_body_str,
43
+ "body_size": len(response_body) if response_body else 0,
44
+ },
45
+ "meta": {
46
+ "library": library,
47
+ "python_version": _python_version(),
48
+ "smello_version": "0.1.0",
49
+ },
50
+ }
51
+
52
+
53
+ def _redact_headers(headers: dict, redact_keys: list[str]) -> dict:
54
+ return {
55
+ k: ("[REDACTED]" if k.lower() in redact_keys else v) for k, v in headers.items()
56
+ }
57
+
58
+
59
+ def _body_to_str(body: str | bytes | None) -> str | None:
60
+ if body is None:
61
+ return None
62
+ if isinstance(body, bytes):
63
+ try:
64
+ return body.decode("utf-8")
65
+ except UnicodeDecodeError:
66
+ return f"<binary: {len(body)} bytes>"
67
+ return body
68
+
69
+
70
+ def _python_version() -> str:
71
+ import sys
72
+
73
+ return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
@@ -0,0 +1,22 @@
1
+ """Configuration for Smello client."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class SmelloConfig:
8
+ server_url: str = "http://localhost:5110"
9
+ capture_hosts: list[str] = field(default_factory=list)
10
+ capture_all: bool = True
11
+ ignore_hosts: list[str] = field(default_factory=list)
12
+ redact_headers: list[str] = field(
13
+ default_factory=lambda: ["authorization", "x-api-key"]
14
+ )
15
+
16
+ def should_capture(self, host: str) -> bool:
17
+ """Decide whether to capture a request to the given host."""
18
+ if host in self.ignore_hosts:
19
+ return False
20
+ if self.capture_all:
21
+ return True
22
+ return host in self.capture_hosts
@@ -0,0 +1,12 @@
1
+ """Monkey-patches for HTTP client libraries."""
2
+
3
+ from smello.config import SmelloConfig
4
+
5
+
6
+ def apply_all(config: SmelloConfig) -> None:
7
+ """Apply all available patches."""
8
+ from smello.patches.patch_httpx import patch_httpx
9
+ from smello.patches.patch_requests import patch_requests
10
+
11
+ patch_requests(config)
12
+ patch_httpx(config)
@@ -0,0 +1,93 @@
1
+ """Monkey-patch for the `httpx` library (sync and async)."""
2
+
3
+ import time
4
+ from urllib.parse import urlparse
5
+
6
+ from smello.config import SmelloConfig
7
+
8
+
9
+ def patch_httpx(config: SmelloConfig) -> None:
10
+ """Patch httpx.Client.send and httpx.AsyncClient.send."""
11
+ try:
12
+ import httpx
13
+ except ImportError:
14
+ return # httpx not installed, skip
15
+
16
+ _patch_sync(httpx, config)
17
+ _patch_async(httpx, config)
18
+
19
+
20
+ def _patch_sync(httpx, config: SmelloConfig) -> None:
21
+ original_send = httpx.Client.send
22
+
23
+ def patched_send(self, request, **kwargs):
24
+ host = urlparse(str(request.url)).hostname or ""
25
+
26
+ if not config.should_capture(host):
27
+ return original_send(self, request, **kwargs)
28
+
29
+ start = time.monotonic()
30
+ response = original_send(self, request, **kwargs)
31
+ duration = time.monotonic() - start
32
+
33
+ try:
34
+ from smello.capture import serialize_request_response
35
+ from smello.transport import send
36
+
37
+ payload = serialize_request_response(
38
+ config=config,
39
+ method=request.method,
40
+ url=str(request.url),
41
+ request_headers=dict(request.headers),
42
+ request_body=request.content,
43
+ status_code=response.status_code,
44
+ response_headers=dict(response.headers),
45
+ response_body=response.text,
46
+ duration_s=duration,
47
+ library="httpx",
48
+ )
49
+ send(payload)
50
+ except Exception:
51
+ pass
52
+
53
+ return response
54
+
55
+ httpx.Client.send = patched_send
56
+
57
+
58
+ def _patch_async(httpx, config: SmelloConfig) -> None:
59
+ original_send = httpx.AsyncClient.send
60
+
61
+ async def patched_send(self, request, **kwargs):
62
+ host = urlparse(str(request.url)).hostname or ""
63
+
64
+ if not config.should_capture(host):
65
+ return await original_send(self, request, **kwargs)
66
+
67
+ start = time.monotonic()
68
+ response = await original_send(self, request, **kwargs)
69
+ duration = time.monotonic() - start
70
+
71
+ try:
72
+ from smello.capture import serialize_request_response
73
+ from smello.transport import send
74
+
75
+ payload = serialize_request_response(
76
+ config=config,
77
+ method=request.method,
78
+ url=str(request.url),
79
+ request_headers=dict(request.headers),
80
+ request_body=request.content,
81
+ status_code=response.status_code,
82
+ response_headers=dict(response.headers),
83
+ response_body=response.text,
84
+ duration_s=duration,
85
+ library="httpx",
86
+ )
87
+ send(payload)
88
+ except Exception:
89
+ pass
90
+
91
+ return response
92
+
93
+ httpx.AsyncClient.send = patched_send
@@ -0,0 +1,50 @@
1
+ """Monkey-patch for the `requests` library."""
2
+
3
+ import time
4
+ from urllib.parse import urlparse
5
+
6
+ from smello.config import SmelloConfig
7
+
8
+
9
+ def patch_requests(config: SmelloConfig) -> None:
10
+ """Patch requests.Session.send to capture outgoing HTTP traffic."""
11
+ try:
12
+ import requests
13
+ except ImportError:
14
+ return # requests not installed, skip
15
+
16
+ original_send = requests.Session.send
17
+
18
+ def patched_send(self, prepared_request, **kwargs):
19
+ host = urlparse(prepared_request.url).hostname or ""
20
+
21
+ if not config.should_capture(host):
22
+ return original_send(self, prepared_request, **kwargs)
23
+
24
+ start = time.monotonic()
25
+ response = original_send(self, prepared_request, **kwargs)
26
+ duration = time.monotonic() - start
27
+
28
+ try:
29
+ from smello.capture import serialize_request_response
30
+ from smello.transport import send
31
+
32
+ payload = serialize_request_response(
33
+ config=config,
34
+ method=prepared_request.method or "GET",
35
+ url=prepared_request.url,
36
+ request_headers=dict(prepared_request.headers),
37
+ request_body=prepared_request.body,
38
+ status_code=response.status_code,
39
+ response_headers=dict(response.headers),
40
+ response_body=response.text,
41
+ duration_s=duration,
42
+ library="requests",
43
+ )
44
+ send(payload)
45
+ except Exception:
46
+ pass # never break user's code
47
+
48
+ return response
49
+
50
+ requests.Session.send = patched_send # type: ignore[assignment]
@@ -0,0 +1,49 @@
1
+ """Background transport: sends captured data to the Smello server without blocking."""
2
+
3
+ import json
4
+ import queue
5
+ import threading
6
+ import urllib.request
7
+
8
+ _queue: queue.Queue = queue.Queue(maxsize=1000)
9
+ _server_url: str = ""
10
+
11
+
12
+ def start_worker(server_url: str) -> None:
13
+ """Start the background worker thread."""
14
+ global _server_url
15
+ _server_url = server_url
16
+
17
+ thread = threading.Thread(target=_worker, daemon=True, name="smello-transport")
18
+ thread.start()
19
+
20
+
21
+ def send(payload: dict) -> None:
22
+ """Queue a capture payload for sending. Non-blocking, drops if queue is full."""
23
+ try:
24
+ _queue.put_nowait(payload)
25
+ except queue.Full:
26
+ pass # drop silently if queue is full
27
+
28
+
29
+ def _worker() -> None:
30
+ """Background worker that sends queued payloads to the server."""
31
+ while True:
32
+ payload = _queue.get()
33
+ try:
34
+ _send_to_server(payload)
35
+ except Exception:
36
+ pass # silently drop if server is down
37
+ _queue.task_done()
38
+
39
+
40
+ def _send_to_server(payload: dict) -> None:
41
+ """Send a payload to the Smello server using urllib (to avoid recursion)."""
42
+ data = json.dumps(payload).encode("utf-8")
43
+ req = urllib.request.Request(
44
+ f"{_server_url}/api/capture",
45
+ data=data,
46
+ headers={"Content-Type": "application/json"},
47
+ method="POST",
48
+ )
49
+ urllib.request.urlopen(req, timeout=5)
@@ -0,0 +1,155 @@
1
+ """Tests for smello.capture serialization."""
2
+
3
+ import pytest
4
+ from smello.capture import serialize_request_response
5
+ from smello.config import SmelloConfig
6
+
7
+
8
+ @pytest.fixture()
9
+ def config():
10
+ return SmelloConfig(redact_headers=["authorization", "x-api-key"])
11
+
12
+
13
+ @pytest.fixture()
14
+ def basic_payload(config):
15
+ return serialize_request_response(
16
+ config=config,
17
+ method="GET",
18
+ url="https://api.example.com/test",
19
+ request_headers={"Content-Type": "application/json"},
20
+ request_body=None,
21
+ status_code=200,
22
+ response_headers={"Content-Type": "application/json"},
23
+ response_body='{"ok": true}',
24
+ duration_s=0.15,
25
+ library="requests",
26
+ )
27
+
28
+
29
+ def test_basic_fields(basic_payload):
30
+ assert basic_payload["duration_ms"] == 150
31
+ assert basic_payload["request"]["method"] == "GET"
32
+ assert basic_payload["request"]["url"] == "https://api.example.com/test"
33
+ assert basic_payload["response"]["status_code"] == 200
34
+ assert basic_payload["response"]["body"] == '{"ok": true}'
35
+ assert basic_payload["meta"]["library"] == "requests"
36
+ assert "id" in basic_payload
37
+ assert "timestamp" in basic_payload
38
+
39
+
40
+ def test_null_body(basic_payload):
41
+ assert basic_payload["request"]["body"] is None
42
+ assert basic_payload["request"]["body_size"] == 0
43
+
44
+
45
+ def test_header_redaction(config):
46
+ payload = serialize_request_response(
47
+ config=config,
48
+ method="POST",
49
+ url="https://example.com",
50
+ request_headers={
51
+ "Content-Type": "application/json",
52
+ "Authorization": "Bearer sk-secret",
53
+ "X-Api-Key": "key_12345",
54
+ "X-Custom": "keep-this",
55
+ },
56
+ request_body=None,
57
+ status_code=200,
58
+ response_headers={},
59
+ response_body=None,
60
+ duration_s=0.1,
61
+ library="httpx",
62
+ )
63
+ headers = payload["request"]["headers"]
64
+ assert headers["Content-Type"] == "application/json"
65
+ assert headers["Authorization"] == "[REDACTED]"
66
+ assert headers["X-Api-Key"] == "[REDACTED]"
67
+ assert headers["X-Custom"] == "keep-this"
68
+
69
+
70
+ def test_custom_redact_headers():
71
+ config = SmelloConfig(redact_headers=["x-secret"])
72
+ payload = serialize_request_response(
73
+ config=config,
74
+ method="GET",
75
+ url="https://example.com",
76
+ request_headers={"Authorization": "Bearer token", "X-Secret": "hidden"},
77
+ request_body=None,
78
+ status_code=200,
79
+ response_headers={},
80
+ response_body=None,
81
+ duration_s=0.05,
82
+ library="requests",
83
+ )
84
+ headers = payload["request"]["headers"]
85
+ assert headers["Authorization"] == "Bearer token"
86
+ assert headers["X-Secret"] == "[REDACTED]"
87
+
88
+
89
+ def test_bytes_body_utf8(config):
90
+ payload = serialize_request_response(
91
+ config=config,
92
+ method="POST",
93
+ url="https://example.com",
94
+ request_headers={},
95
+ request_body=b'{"key": "value"}',
96
+ status_code=200,
97
+ response_headers={},
98
+ response_body=b'{"result": "ok"}',
99
+ duration_s=0.1,
100
+ library="requests",
101
+ )
102
+ assert payload["request"]["body"] == '{"key": "value"}'
103
+ assert payload["request"]["body_size"] == 16
104
+ assert payload["response"]["body"] == '{"result": "ok"}'
105
+
106
+
107
+ def test_binary_body_non_utf8(config):
108
+ binary_data = bytes(range(256))
109
+ payload = serialize_request_response(
110
+ config=config,
111
+ method="POST",
112
+ url="https://example.com",
113
+ request_headers={},
114
+ request_body=binary_data,
115
+ status_code=200,
116
+ response_headers={},
117
+ response_body=None,
118
+ duration_s=0.1,
119
+ library="requests",
120
+ )
121
+ assert payload["request"]["body"] == "<binary: 256 bytes>"
122
+ assert payload["request"]["body_size"] == 256
123
+
124
+
125
+ def test_string_body(config):
126
+ payload = serialize_request_response(
127
+ config=config,
128
+ method="POST",
129
+ url="https://example.com",
130
+ request_headers={},
131
+ request_body="plain text",
132
+ status_code=200,
133
+ response_headers={},
134
+ response_body="response text",
135
+ duration_s=0.1,
136
+ library="httpx",
137
+ )
138
+ assert payload["request"]["body"] == "plain text"
139
+ assert payload["response"]["body"] == "response text"
140
+
141
+
142
+ def test_duration_rounding(config):
143
+ payload = serialize_request_response(
144
+ config=config,
145
+ method="GET",
146
+ url="https://example.com",
147
+ request_headers={},
148
+ request_body=None,
149
+ status_code=200,
150
+ response_headers={},
151
+ response_body=None,
152
+ duration_s=1.5678,
153
+ library="requests",
154
+ )
155
+ assert payload["duration_ms"] == 1567
@@ -0,0 +1,46 @@
1
+ """Tests for smello.config.SmelloConfig."""
2
+
3
+ import pytest
4
+ from smello.config import SmelloConfig
5
+
6
+
7
+ @pytest.fixture()
8
+ def default_config():
9
+ return SmelloConfig()
10
+
11
+
12
+ @pytest.fixture()
13
+ def selective_config():
14
+ return SmelloConfig(capture_all=False, capture_hosts=["api.stripe.com"])
15
+
16
+
17
+ def test_capture_all_by_default(default_config):
18
+ assert default_config.should_capture("api.stripe.com") is True
19
+ assert default_config.should_capture("example.com") is True
20
+
21
+
22
+ def test_ignore_hosts():
23
+ config = SmelloConfig(ignore_hosts=["localhost", "127.0.0.1"])
24
+ assert config.should_capture("localhost") is False
25
+ assert config.should_capture("127.0.0.1") is False
26
+ assert config.should_capture("api.stripe.com") is True
27
+
28
+
29
+ def test_capture_specific_hosts_only(selective_config):
30
+ assert selective_config.should_capture("api.stripe.com") is True
31
+ assert selective_config.should_capture("api.openai.com") is False
32
+
33
+
34
+ def test_ignore_takes_precedence_over_capture_hosts():
35
+ config = SmelloConfig(
36
+ capture_all=False,
37
+ capture_hosts=["api.stripe.com"],
38
+ ignore_hosts=["api.stripe.com"],
39
+ )
40
+ assert config.should_capture("api.stripe.com") is False
41
+
42
+
43
+ def test_ignore_takes_precedence_over_capture_all():
44
+ config = SmelloConfig(capture_all=True, ignore_hosts=["secret.internal"])
45
+ assert config.should_capture("secret.internal") is False
46
+ assert config.should_capture("anything.else") is True
@@ -0,0 +1,55 @@
1
+ """Tests for smello.transport."""
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+
8
+ import pytest
9
+ from smello.transport import send, start_worker
10
+
11
+
12
+ class _CaptureHandler(BaseHTTPRequestHandler):
13
+ captured: list = []
14
+
15
+ def do_POST(self):
16
+ length = int(self.headers.get("Content-Length", 0))
17
+ body = json.loads(self.rfile.read(length))
18
+ _CaptureHandler.captured.append(body)
19
+ self.send_response(201)
20
+ self.end_headers()
21
+ self.wfile.write(b'{"status":"ok"}')
22
+
23
+ def log_message(self, format, *args):
24
+ pass
25
+
26
+
27
+ @pytest.fixture()
28
+ def capture_server():
29
+ """Start a minimal HTTP server that records POSTed payloads."""
30
+ _CaptureHandler.captured = []
31
+ server = HTTPServer(("127.0.0.1", 0), _CaptureHandler)
32
+ port = server.server_address[1]
33
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
34
+ thread.start()
35
+ yield f"http://127.0.0.1:{port}", _CaptureHandler.captured
36
+ server.shutdown()
37
+
38
+
39
+ def test_send_delivers_payload(capture_server):
40
+ url, captured = capture_server
41
+ start_worker(url)
42
+
43
+ payload = {
44
+ "id": "test-transport-1",
45
+ "request": {"method": "GET", "url": "https://example.com"},
46
+ "response": {"status_code": 200},
47
+ }
48
+ send(payload)
49
+
50
+ deadline = time.monotonic() + 5
51
+ while not captured and time.monotonic() < deadline:
52
+ time.sleep(0.05)
53
+
54
+ assert len(captured) == 1
55
+ assert captured[0]["id"] == "test-transport-1"