smello-server 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-server/v{new_version}"
6
+ tag_message = "Release smello-server v{new_version}"
7
+ message = "Bump smello-server version: {current_version} → {new_version}"
8
+
9
+ [[tool.bumpversion.files]]
10
+ filename = "server/pyproject.toml"
11
+ key_path = "project.version"
12
+
13
+ [[tool.bumpversion.files]]
14
+ filename = "server/src/smello_server/__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
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: smello-server
3
+ Version: 0.1.1
4
+ Summary: A local web dashboard for inspecting outgoing HTTP requests from your code
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: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Topic :: System :: Networking :: Monitoring
18
+ Requires-Python: >=3.14
19
+ Requires-Dist: aiosqlite>=0.20.0
20
+ Requires-Dist: fastapi>=0.115.0
21
+ Requires-Dist: jinja2>=3.1.0
22
+ Requires-Dist: tortoise-orm>=0.22.0
23
+ Requires-Dist: uvicorn[standard]>=0.34.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Smello Server
27
+
28
+ A local web dashboard for inspecting outgoing HTTP requests captured by the [smello](https://pypi.org/project/smello/) client SDK.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ pip install smello-server
34
+ smello-server run
35
+ ```
36
+
37
+ The dashboard opens at `http://localhost:5110`.
38
+
39
+ Or with Docker:
40
+
41
+ ```bash
42
+ docker run -p 5110:5110 ghcr.io/smelloscope/smello
43
+ ```
44
+
45
+ Then add the client SDK to your Python code:
46
+
47
+ ```bash
48
+ pip install smello
49
+ ```
50
+
51
+ ```python
52
+ import smello
53
+ smello.init()
54
+
55
+ # All outgoing requests via requests/httpx are now captured
56
+ ```
57
+
58
+ ## API
59
+
60
+ Smello Server provides a JSON API for exploring captured requests from the command line.
61
+
62
+ ```bash
63
+ # List all captured requests
64
+ curl -s http://localhost:5110/api/requests | python -m json.tool
65
+
66
+ # Filter by method, host, status, or URL substring
67
+ curl -s 'http://localhost:5110/api/requests?method=POST&host=api.stripe.com'
68
+
69
+ # Get full request/response details
70
+ curl -s http://localhost:5110/api/requests/{id} | python -m json.tool
71
+
72
+ # Clear all requests
73
+ curl -X DELETE http://localhost:5110/api/requests
74
+ ```
75
+
76
+ ## CLI Options
77
+
78
+ ```bash
79
+ smello-server run --host 0.0.0.0 --port 5110 --db-path /tmp/smello.db
80
+ ```
81
+
82
+ ## Requires
83
+
84
+ - Python >= 3.14
85
+
86
+ ## Links
87
+
88
+ - [Documentation & Source](https://github.com/smelloscope/smello)
89
+ - [smello client SDK on PyPI](https://pypi.org/project/smello/)
@@ -0,0 +1,64 @@
1
+ # Smello Server
2
+
3
+ A local web dashboard for inspecting outgoing HTTP requests captured by the [smello](https://pypi.org/project/smello/) client SDK.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ pip install smello-server
9
+ smello-server run
10
+ ```
11
+
12
+ The dashboard opens at `http://localhost:5110`.
13
+
14
+ Or with Docker:
15
+
16
+ ```bash
17
+ docker run -p 5110:5110 ghcr.io/smelloscope/smello
18
+ ```
19
+
20
+ Then add the client SDK to your Python code:
21
+
22
+ ```bash
23
+ pip install smello
24
+ ```
25
+
26
+ ```python
27
+ import smello
28
+ smello.init()
29
+
30
+ # All outgoing requests via requests/httpx are now captured
31
+ ```
32
+
33
+ ## API
34
+
35
+ Smello Server provides a JSON API for exploring captured requests from the command line.
36
+
37
+ ```bash
38
+ # List all captured requests
39
+ curl -s http://localhost:5110/api/requests | python -m json.tool
40
+
41
+ # Filter by method, host, status, or URL substring
42
+ curl -s 'http://localhost:5110/api/requests?method=POST&host=api.stripe.com'
43
+
44
+ # Get full request/response details
45
+ curl -s http://localhost:5110/api/requests/{id} | python -m json.tool
46
+
47
+ # Clear all requests
48
+ curl -X DELETE http://localhost:5110/api/requests
49
+ ```
50
+
51
+ ## CLI Options
52
+
53
+ ```bash
54
+ smello-server run --host 0.0.0.0 --port 5110 --db-path /tmp/smello.db
55
+ ```
56
+
57
+ ## Requires
58
+
59
+ - Python >= 3.14
60
+
61
+ ## Links
62
+
63
+ - [Documentation & Source](https://github.com/smelloscope/smello)
64
+ - [smello client SDK on PyPI](https://pypi.org/project/smello/)
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "smello-server"
3
+ version = "0.1.1"
4
+ description = "A local web dashboard for inspecting outgoing HTTP requests from your code"
5
+ license = "MIT"
6
+ requires-python = ">=3.14"
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.14",
15
+ "Framework :: FastAPI",
16
+ "Topic :: Software Development :: Testing",
17
+ "Topic :: System :: Networking :: Monitoring",
18
+ ]
19
+ dependencies = [
20
+ "fastapi>=0.115.0",
21
+ "uvicorn[standard]>=0.34.0",
22
+ "jinja2>=3.1.0",
23
+ "tortoise-orm>=0.22.0",
24
+ "aiosqlite>=0.20.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/smelloscope/smello"
29
+ Repository = "https://github.com/smelloscope/smello"
30
+ Issues = "https://github.com/smelloscope/smello/issues"
31
+
32
+ [project.scripts]
33
+ smello-server = "smello_server.__main__:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/smello_server"]
@@ -0,0 +1,3 @@
1
+ """Smello Server - HTTP request inspection dashboard."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,50 @@
1
+ """CLI entry point: `smello-server run` or `python -m smello_server`."""
2
+
3
+ import argparse
4
+ import os
5
+
6
+ import uvicorn
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="smello-server", description="Smello HTTP request inspector"
12
+ )
13
+ subparsers = parser.add_subparsers(dest="command")
14
+
15
+ run_parser = subparsers.add_parser("run", help="Start the Smello server")
16
+ run_parser.add_argument(
17
+ "--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
18
+ )
19
+ run_parser.add_argument(
20
+ "--port", type=int, default=5110, help="Port to bind to (default: 5110)"
21
+ )
22
+ run_parser.add_argument(
23
+ "--db-path", default=None, help="Path to SQLite database file"
24
+ )
25
+
26
+ args = parser.parse_args()
27
+
28
+ if args.command is None:
29
+ args.command = "run"
30
+ args.host = "0.0.0.0"
31
+ args.port = 5110
32
+ args.db_path = None
33
+
34
+ if args.command == "run":
35
+ if args.db_path:
36
+ os.environ["SMELLO_DB_PATH"] = args.db_path
37
+
38
+ from smello_server.app import create_app
39
+
40
+ app = create_app()
41
+ uvicorn.run(
42
+ app,
43
+ host=args.host,
44
+ port=args.port,
45
+ log_level="info",
46
+ )
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
@@ -0,0 +1,47 @@
1
+ """FastAPI application setup with Tortoise ORM."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.templating import Jinja2Templates
9
+ from tortoise.contrib.fastapi import register_tortoise
10
+
11
+ from smello_server.routes.api import router as api_router
12
+ from smello_server.routes.web import router as web_router
13
+
14
+ PACKAGE_DIR = Path(__file__).parent
15
+ TEMPLATES_DIR = PACKAGE_DIR / "templates"
16
+ STATIC_DIR = PACKAGE_DIR / "static"
17
+
18
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
19
+
20
+
21
+ def _get_db_url() -> str:
22
+ db_path = os.environ.get("SMELLO_DB_PATH")
23
+ if db_path:
24
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
25
+ return f"sqlite://{db_path}"
26
+ default_dir = Path.home() / ".smello"
27
+ default_dir.mkdir(parents=True, exist_ok=True)
28
+ return f"sqlite://{default_dir / 'smello.db'}"
29
+
30
+
31
+ def create_app(db_url: str | None = None) -> FastAPI:
32
+ """Create and configure the FastAPI application."""
33
+ application = FastAPI(title="Smello")
34
+
35
+ application.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
36
+ application.include_router(api_router)
37
+ application.include_router(web_router)
38
+
39
+ register_tortoise(
40
+ application,
41
+ db_url=db_url or _get_db_url(),
42
+ modules={"models": ["smello_server.models"]},
43
+ generate_schemas=True,
44
+ add_exception_handlers=True,
45
+ )
46
+
47
+ return application
@@ -0,0 +1,31 @@
1
+ """Tortoise ORM models for captured HTTP requests."""
2
+
3
+ from tortoise import fields
4
+ from tortoise.models import Model
5
+
6
+
7
+ class CapturedRequest(Model):
8
+ id = fields.UUIDField(pk=True)
9
+ timestamp = fields.DatetimeField(auto_now_add=True)
10
+ duration_ms = fields.IntField()
11
+
12
+ # Request
13
+ method = fields.CharField(max_length=10)
14
+ url = fields.TextField()
15
+ request_headers: dict = fields.JSONField()
16
+ request_body = fields.TextField(null=True)
17
+ request_body_size = fields.IntField(default=0)
18
+
19
+ # Response
20
+ status_code = fields.IntField()
21
+ response_headers: dict = fields.JSONField()
22
+ response_body = fields.TextField(null=True)
23
+ response_body_size = fields.IntField(default=0)
24
+
25
+ # Meta
26
+ host = fields.CharField(max_length=255, index=True)
27
+ library = fields.CharField(max_length=50)
28
+
29
+ class Meta:
30
+ table = "captured_requests"
31
+ ordering = ["-timestamp"]
@@ -0,0 +1,160 @@
1
+ """API routes: ingestion endpoint and JSON API."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from urllib.parse import urlparse
6
+
7
+ from fastapi import APIRouter, HTTPException, Query
8
+ from pydantic import BaseModel
9
+
10
+ from smello_server.models import CapturedRequest
11
+
12
+ router = APIRouter(prefix="/api")
13
+
14
+
15
+ # --- Input models ---
16
+
17
+
18
+ class RequestData(BaseModel):
19
+ method: str
20
+ url: str
21
+ headers: dict[str, str]
22
+ body: str | None = None
23
+ body_size: int = 0
24
+
25
+
26
+ class ResponseData(BaseModel):
27
+ status_code: int
28
+ headers: dict[str, str]
29
+ body: str | None = None
30
+ body_size: int = 0
31
+
32
+
33
+ class MetaData(BaseModel):
34
+ library: str = "unknown"
35
+ python_version: str = ""
36
+ smello_version: str = ""
37
+
38
+
39
+ class CapturePayload(BaseModel):
40
+ id: str | None = None
41
+ timestamp: str | None = None
42
+ duration_ms: int = 0
43
+ request: RequestData
44
+ response: ResponseData
45
+ meta: MetaData = MetaData()
46
+
47
+
48
+ # --- Output models ---
49
+
50
+
51
+ class CaptureResponse(BaseModel):
52
+ status: str
53
+
54
+
55
+ class RequestSummary(BaseModel):
56
+ id: str
57
+ timestamp: datetime
58
+ method: str
59
+ url: str
60
+ host: str
61
+ status_code: int
62
+ duration_ms: int
63
+
64
+
65
+ class RequestDetail(RequestSummary):
66
+ library: str
67
+ request_headers: dict[str, str]
68
+ request_body: str | None
69
+ request_body_size: int
70
+ response_headers: dict[str, str]
71
+ response_body: str | None
72
+ response_body_size: int
73
+
74
+
75
+ # --- Routes ---
76
+
77
+
78
+ @router.post("/capture", status_code=201, response_model=CaptureResponse)
79
+ async def capture(payload: CapturePayload) -> CaptureResponse:
80
+ host = urlparse(payload.request.url).hostname or "unknown"
81
+
82
+ await CapturedRequest.create(
83
+ id=payload.id or uuid.uuid4(),
84
+ duration_ms=payload.duration_ms,
85
+ method=payload.request.method.upper(),
86
+ url=payload.request.url,
87
+ request_headers=payload.request.headers,
88
+ request_body=payload.request.body,
89
+ request_body_size=payload.request.body_size,
90
+ status_code=payload.response.status_code,
91
+ response_headers=payload.response.headers,
92
+ response_body=payload.response.body,
93
+ response_body_size=payload.response.body_size,
94
+ host=host,
95
+ library=payload.meta.library,
96
+ )
97
+ return CaptureResponse(status="ok")
98
+
99
+
100
+ @router.get("/requests", response_model=list[RequestSummary])
101
+ async def list_requests(
102
+ host: str | None = Query(None),
103
+ method: str | None = Query(None),
104
+ status: int | None = Query(None),
105
+ search: str | None = Query(None),
106
+ limit: int = Query(50, le=200),
107
+ ) -> list[RequestSummary]:
108
+ qs = CapturedRequest.all()
109
+ if host:
110
+ qs = qs.filter(host=host)
111
+ if method:
112
+ qs = qs.filter(method=method.upper())
113
+ if status:
114
+ qs = qs.filter(status_code=status)
115
+ if search:
116
+ qs = qs.filter(url__icontains=search)
117
+
118
+ requests = await qs.limit(limit)
119
+ return [
120
+ RequestSummary(
121
+ id=str(r.id),
122
+ timestamp=r.timestamp,
123
+ method=r.method,
124
+ url=r.url,
125
+ host=r.host,
126
+ status_code=r.status_code,
127
+ duration_ms=r.duration_ms,
128
+ )
129
+ for r in requests
130
+ ]
131
+
132
+
133
+ @router.get("/requests/{request_id}", response_model=RequestDetail)
134
+ async def get_request(request_id: str) -> RequestDetail:
135
+ try:
136
+ r = await CapturedRequest.get(id=request_id)
137
+ except Exception:
138
+ raise HTTPException(status_code=404, detail="Request not found")
139
+
140
+ return RequestDetail(
141
+ id=str(r.id),
142
+ timestamp=r.timestamp,
143
+ method=r.method,
144
+ url=r.url,
145
+ host=r.host,
146
+ status_code=r.status_code,
147
+ duration_ms=r.duration_ms,
148
+ library=r.library,
149
+ request_headers=r.request_headers,
150
+ request_body=r.request_body,
151
+ request_body_size=r.request_body_size,
152
+ response_headers=r.response_headers,
153
+ response_body=r.response_body,
154
+ response_body_size=r.response_body_size,
155
+ )
156
+
157
+
158
+ @router.delete("/requests", status_code=204)
159
+ async def clear_requests() -> None:
160
+ await CapturedRequest.all().delete()
@@ -0,0 +1,82 @@
1
+ """Web UI routes: request list and detail pages."""
2
+
3
+ from fastapi import APIRouter, Query, Request
4
+ from fastapi.responses import HTMLResponse
5
+
6
+ from smello_server.models import CapturedRequest
7
+
8
+ router = APIRouter(include_in_schema=False)
9
+
10
+
11
+ @router.get("/", response_class=HTMLResponse)
12
+ async def request_list(
13
+ request: Request,
14
+ host: str | None = Query(None),
15
+ method: str | None = Query(None),
16
+ status: int | None = Query(None),
17
+ search: str | None = Query(None),
18
+ _partial: str | None = Query(None),
19
+ ):
20
+ from smello_server.app import templates
21
+
22
+ qs = CapturedRequest.all()
23
+ if host:
24
+ qs = qs.filter(host=host)
25
+ if method:
26
+ qs = qs.filter(method=method.upper())
27
+ if status:
28
+ qs = qs.filter(status_code=status)
29
+ if search:
30
+ qs = qs.filter(url__icontains=search)
31
+
32
+ requests_list = await qs.limit(100)
33
+
34
+ hosts = await CapturedRequest.all().distinct().values_list("host", flat=True)
35
+ methods = await CapturedRequest.all().distinct().values_list("method", flat=True)
36
+
37
+ context = {
38
+ "request": request,
39
+ "requests": requests_list,
40
+ "hosts": sorted(set(hosts)),
41
+ "methods": sorted(set(methods)),
42
+ "filter_host": host or "",
43
+ "filter_method": method or "",
44
+ "filter_status": status or "",
45
+ "filter_search": search or "",
46
+ "selected_id": "",
47
+ }
48
+
49
+ if _partial == "list":
50
+ return templates.TemplateResponse("partials/request_list_items.html", context)
51
+
52
+ return templates.TemplateResponse("request_list.html", context)
53
+
54
+
55
+ @router.get("/requests/{request_id}", response_class=HTMLResponse)
56
+ async def request_detail(request: Request, request_id: str):
57
+ from smello_server.app import templates
58
+
59
+ captured = await CapturedRequest.get(id=request_id)
60
+
61
+ return templates.TemplateResponse(
62
+ "request_detail.html",
63
+ {
64
+ "request": request,
65
+ "captured": captured,
66
+ },
67
+ )
68
+
69
+
70
+ @router.get("/requests/{request_id}/partial", response_class=HTMLResponse)
71
+ async def request_detail_partial(request: Request, request_id: str):
72
+ from smello_server.app import templates
73
+
74
+ captured = await CapturedRequest.get(id=request_id)
75
+
76
+ return templates.TemplateResponse(
77
+ "partials/request_detail_partial.html",
78
+ {
79
+ "request": request,
80
+ "captured": captured,
81
+ },
82
+ )