one-ring-http 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,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+
8
+ # Virtual environments
9
+ .venv/
10
+
11
+ # Testing
12
+ .coverage
13
+ htmlcov/
14
+ .pytest_cache/
15
+
16
+ # Tools
17
+ .ruff_cache/
18
+
19
+ # IDE
20
+ .idea/
21
+ .vscode/*
22
+ !.vscode/settings.json
23
+ !.vscode/extensions.json
24
+ *.swp
25
+ *.swo
26
+
27
+ # Docs
28
+ site/
29
+
30
+ # Secrets
31
+ .env
32
+ .env.*
33
+ *.pem
34
+
35
+ # OS
36
+ .DS_Store
37
+ Thumbs.db
38
+
39
+ # Personal testing.
40
+ tmp/
41
+
42
+ # Agents
43
+ .claude
@@ -0,0 +1,28 @@
1
+ # one-ring-http — Package Context
2
+
3
+ Part of the **one-ring** monorepo. See the root `CLAUDE.md` for shared conventions.
4
+
5
+ ## Purpose
6
+
7
+ <!-- Describe what this package does -->
8
+
9
+ ## Package Commands
10
+
11
+ ```bash
12
+ # From this directory:
13
+ just test # Run tests for this package
14
+ just test-cov # Tests with coverage
15
+ just typecheck # Type check this package
16
+
17
+ # From monorepo root:
18
+ just test-pkg one-ring-http # Test this package
19
+ just check # Run all checks (all packages)
20
+ ```
21
+
22
+ ## Layout
23
+
24
+ ```
25
+ src/one_ring_http/ # Source code
26
+ tests/ # Tests
27
+ pyproject.toml # Package metadata (tool config inherited from root)
28
+ ```
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: one-ring-http
3
+ Version: 0.1.0
4
+ Summary: HTTP/1.1 server built on one-ring-loop and io_uring
5
+ Project-URL: Homepage, https://github.com/otto-sellerstam/one-ring
6
+ Project-URL: Repository, https://github.com/otto-sellerstam/one-ring
7
+ Project-URL: Issues, https://github.com/otto-sellerstam/one-ring/issues
8
+ Author-email: Otto Sellerstam <ottosellerstam@gmail.com>
9
+ License: MIT
10
+ Keywords: async,http,io_uring,linux,server
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.14
20
+ Requires-Dist: one-ring-loop>=0.1.0
21
+ Requires-Dist: structlog>=24.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # one-ring-http
25
+
26
+ HTTP/1.1 server built on [one-ring-loop](https://pypi.org/project/one-ring-loop/) and Linux io_uring.
27
+
28
+ Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
29
+
30
+ ## What it provides
31
+
32
+ - **HTTPServer** - TLS-enabled HTTP/1.1 server
33
+ - **Router** - method + path routing with fallback handlers
34
+ - **Request/Response** - parsed HTTP requests, serializable responses
35
+ - **Static file serving** - built-in static file handler
36
+
37
+ ## Example
38
+
39
+ ```python
40
+ import ssl
41
+
42
+ from one_ring_http.response import Response
43
+ from one_ring_http.router import Router
44
+ from one_ring_http.server import HTTPServer
45
+ from one_ring_loop import run
46
+
47
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
48
+ ssl_context.load_cert_chain("cert.pem", "key.pem")
49
+
50
+ def hello(request):
51
+ return Response(status_code=200, body=b"Hello, world!")
52
+
53
+ router = Router()
54
+ router.add("GET", "/", hello)
55
+
56
+ server = HTTPServer(router=router, host="127.0.0.1", port=8000, ssl_context=ssl_context)
57
+ run(server.serve())
58
+ ```
59
+
60
+ ## Requirements
61
+
62
+ - **Linux** with io_uring support (kernel 6.7+)
63
+ - **Python 3.14+**
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ uv add one-ring-http
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,50 @@
1
+ # one-ring-http
2
+
3
+ HTTP/1.1 server built on [one-ring-loop](https://pypi.org/project/one-ring-loop/) and Linux io_uring.
4
+
5
+ Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
6
+
7
+ ## What it provides
8
+
9
+ - **HTTPServer** - TLS-enabled HTTP/1.1 server
10
+ - **Router** - method + path routing with fallback handlers
11
+ - **Request/Response** - parsed HTTP requests, serializable responses
12
+ - **Static file serving** - built-in static file handler
13
+
14
+ ## Example
15
+
16
+ ```python
17
+ import ssl
18
+
19
+ from one_ring_http.response import Response
20
+ from one_ring_http.router import Router
21
+ from one_ring_http.server import HTTPServer
22
+ from one_ring_loop import run
23
+
24
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
25
+ ssl_context.load_cert_chain("cert.pem", "key.pem")
26
+
27
+ def hello(request):
28
+ return Response(status_code=200, body=b"Hello, world!")
29
+
30
+ router = Router()
31
+ router.add("GET", "/", hello)
32
+
33
+ server = HTTPServer(router=router, host="127.0.0.1", port=8000, ssl_context=ssl_context)
34
+ run(server.serve())
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - **Linux** with io_uring support (kernel 6.7+)
40
+ - **Python 3.14+**
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ uv add one-ring-http
46
+ ```
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,20 @@
1
+ # List available recipes
2
+ default:
3
+ @just --list
4
+
5
+ pkg := "one_ring_http"
6
+
7
+ # Run tests for this package
8
+ test:
9
+ uv run pytest tests/
10
+
11
+ # Run tests with coverage
12
+ test-cov:
13
+ uv run pytest tests/ --cov={{pkg}} --cov-report=term-missing
14
+
15
+ # Run type checker for this package
16
+ typecheck:
17
+ uv run pyrefly check src/ tests/
18
+
19
+ # Run all checks for this package
20
+ check: typecheck test
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "one-ring-http"
3
+ version = "0.1.0"
4
+ description = "HTTP/1.1 server built on one-ring-loop and io_uring"
5
+ requires-python = ">=3.14"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Otto Sellerstam", email = "ottosellerstam@gmail.com" },
9
+ ]
10
+ readme = "README.md"
11
+ keywords = ["io_uring", "http", "server", "async", "linux"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "structlog>=24.0",
24
+ "one-ring-loop>=0.1.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/otto-sellerstam/one-ring"
29
+ Repository = "https://github.com/otto-sellerstam/one-ring"
30
+ Issues = "https://github.com/otto-sellerstam/one-ring/issues"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/one_ring_http"]
38
+
39
+ # ── Coverage (per-package source) ───────────────────────────────────────
40
+ # Other coverage settings (fail_under, exclude_lines) are in root pyproject.toml.
41
+
42
+ [tool.coverage.run]
43
+ source = ["one_ring_http"]
44
+ branch = true
@@ -0,0 +1,3 @@
1
+ """One Ring Http package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,55 @@
1
+ """Structured logging configuration using structlog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import structlog
9
+
10
+
11
+ def setup_logging() -> None:
12
+ """Configure structlog with sensible defaults.
13
+
14
+ Uses JSON output when ``LOG_FORMAT=json`` (e.g. production),
15
+ otherwise uses colored console output for development.
16
+ """
17
+ shared_processors: list[structlog.types.Processor] = [
18
+ structlog.contextvars.merge_contextvars,
19
+ structlog.stdlib.add_log_level,
20
+ structlog.processors.TimeStamper(fmt="iso"),
21
+ structlog.processors.StackInfoRenderer(),
22
+ structlog.processors.UnicodeDecoder(),
23
+ ]
24
+
25
+ if os.environ.get("LOG_FORMAT") == "json":
26
+ renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
27
+ else:
28
+ renderer = structlog.dev.ConsoleRenderer()
29
+
30
+ structlog.configure(
31
+ processors=[
32
+ *shared_processors,
33
+ renderer,
34
+ ],
35
+ wrapper_class=structlog.make_filtering_bound_logger(0),
36
+ context_class=dict,
37
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
38
+ cache_logger_on_first_use=True,
39
+ )
40
+
41
+
42
+ _configured = False
43
+
44
+
45
+ def get_logger(*args: object, **kwargs: object) -> structlog.stdlib.BoundLogger:
46
+ """Return a structlog logger, configuring on first call.
47
+
48
+ This avoids running ``setup_logging()`` at import time, which would
49
+ interfere with tests and multi-process setups.
50
+ """
51
+ global _configured # noqa: PLW0603
52
+ if not _configured:
53
+ setup_logging()
54
+ _configured = True
55
+ return structlog.get_logger(*args, **kwargs)
File without changes
@@ -0,0 +1,86 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Self, TypeGuard
3
+
4
+ from one_ring_loop.log import get_logger
5
+
6
+ if TYPE_CHECKING:
7
+ from one_ring_http.typedef import HTTPHeaders, HTTPMethod
8
+ from one_ring_loop.streams.buffered import BufferedByteReceiveStream
9
+ from one_ring_loop.typedefs import Coro
10
+
11
+
12
+ logger = get_logger()
13
+
14
+ ALLOWED_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
15
+
16
+
17
+ @dataclass(frozen=True, slots=True, kw_only=True)
18
+ class Request:
19
+ """Represents a parsed HTTP/1.1 request."""
20
+
21
+ """The HTTP method"""
22
+ method: HTTPMethod
23
+
24
+ """The URL path"""
25
+ path: str
26
+
27
+ """HTTP version"""
28
+ http_version: str
29
+
30
+ """HTTP headers"""
31
+ headers: HTTPHeaders
32
+
33
+ """HTTP body"""
34
+ body: bytes
35
+
36
+ @classmethod
37
+ def parse(cls, buffered_stream: BufferedByteReceiveStream) -> Coro[Self]:
38
+ """Parses data from a buffered receive stream and provides a Request object."""
39
+ # 1. Get tokens from first line
40
+ first_line = yield from buffered_stream.receive_until(
41
+ delimiter=b"\r\n", max_bytes=65536
42
+ )
43
+ tokens = first_line.split(b" ")
44
+ method = tokens[0].decode()
45
+ target = tokens[1].decode()
46
+ version = tokens[2].decode()
47
+
48
+ if not cls.verify_http_method(method):
49
+ raise RuntimeError
50
+
51
+ # 2. Get headers, until reaching empty line
52
+ headers: HTTPHeaders = {}
53
+ line = yield from buffered_stream.receive_until(
54
+ delimiter=b"\r\n", max_bytes=65536
55
+ )
56
+ while line:
57
+ key_val = line.split(b": ", 1)
58
+ header_name = key_val[0].decode().lower()
59
+ header_val = key_val[1].decode()
60
+
61
+ if header_name in headers:
62
+ header_val = headers[header_name] + ", " + header_val
63
+
64
+ headers[header_name] = header_val
65
+ line = yield from buffered_stream.receive_until(
66
+ delimiter=b"\r\n", max_bytes=65536
67
+ )
68
+
69
+ # 3. Get body, if we have "content-length"
70
+ body = b""
71
+ if "content-length" in headers:
72
+ content_length = int(headers["content-length"])
73
+ body = yield from buffered_stream.receive_exactly(content_length)
74
+
75
+ return cls(
76
+ method=method,
77
+ path=target,
78
+ http_version=version,
79
+ headers=headers,
80
+ body=body,
81
+ )
82
+
83
+ @staticmethod
84
+ def verify_http_method(method: str) -> TypeGuard[HTTPMethod]:
85
+ """Verifies that HTTP method is valid."""
86
+ return method in ALLOWED_HTTP_METHODS
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from one_ring_http.typedef import HTTPHeaders
6
+
7
+
8
+ @dataclass(frozen=True, slots=True, kw_only=True)
9
+ class Response:
10
+ """A HTTP/1.1 response to be serialized."""
11
+
12
+ """HTTP status code of the response"""
13
+ status_code: int
14
+
15
+ """HTTP headers for the response"""
16
+ headers: HTTPHeaders = field(default_factory=dict)
17
+
18
+ """HTTP body for the response"""
19
+ body: bytes = field(default=b"")
20
+
21
+ def serialize(self) -> bytes:
22
+ """Serializes a response for transfer."""
23
+ # 1. Add first line
24
+ serialized_response = f"HTTP/1.1 {self.status_code}\r\n".encode()
25
+
26
+ # 2. Add headers
27
+ content_length = len(self.body)
28
+ serialized_response += f"content-length: {content_length}\r\n".encode()
29
+
30
+ for header_name, header_val in self.headers.items():
31
+ serialized_response += f"{header_name}: {header_val}\r\n".encode()
32
+
33
+ serialized_response += b"\r\n"
34
+
35
+ # 3. Add body
36
+ serialized_response += self.body
37
+
38
+ return serialized_response
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import TYPE_CHECKING
3
+
4
+ from one_ring_http.response import Response
5
+
6
+ if TYPE_CHECKING:
7
+ from one_ring_http.request import Request
8
+ from one_ring_http.typedef import HTTPHandler, HTTPMethod
9
+
10
+
11
+ def page_not_found(_: Request) -> Response:
12
+ """Default handler for 404."""
13
+ return Response(status_code=404)
14
+
15
+
16
+ @dataclass(slots=True, kw_only=True)
17
+ class Router:
18
+ """Routes HTTP request to handlers."""
19
+
20
+ _registry: dict[tuple[HTTPMethod, str], HTTPHandler] = field(
21
+ default_factory=dict, init=False
22
+ )
23
+
24
+ _fallback: HTTPHandler = field(default=page_not_found, init=False)
25
+
26
+ def add(self, method: HTTPMethod, path: str, handler: HTTPHandler) -> None:
27
+ """Registers a path."""
28
+ self._registry[(method, path)] = handler
29
+
30
+ def resolve(self, method: HTTPMethod, path: str) -> HTTPHandler:
31
+ """Returns the handler for a method and path."""
32
+ handler = self._registry.get((method, path))
33
+ if handler is None:
34
+ return self._fallback
35
+ return handler
36
+
37
+ def set_fallback(self, handler: HTTPHandler) -> None:
38
+ """Sets fallback."""
39
+ self._fallback = handler
@@ -0,0 +1,79 @@
1
+ from collections.abc import Generator
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING
4
+
5
+ from one_ring_http.log import get_logger
6
+ from one_ring_http.request import Request
7
+ from one_ring_loop import TaskGroup
8
+ from one_ring_loop.cancellation import move_on_after
9
+ from one_ring_loop.socketio import Connection, create_server
10
+ from one_ring_loop.streams.buffered import BufferedByteStream
11
+ from one_ring_loop.streams.tls import TLSStream
12
+
13
+ logger = get_logger()
14
+
15
+ if TYPE_CHECKING:
16
+ import ssl
17
+
18
+ from one_ring_http.router import Router
19
+ from one_ring_loop.typedefs import Coro
20
+
21
+
22
+ @dataclass(frozen=True, slots=True, kw_only=True)
23
+ class HTTPServer:
24
+ """A simple HTTP server built on one-ring-loop."""
25
+
26
+ """Routes method and path to a handler"""
27
+ router: Router
28
+
29
+ """The host for the server to run on"""
30
+ host: str
31
+
32
+ """The port for the server to run on"""
33
+ port: int
34
+
35
+ """The SSL context for TLS wrapping"""
36
+ ssl_context: ssl.SSLContext
37
+
38
+ def serve(self) -> Coro[None]:
39
+ """Starts the server."""
40
+ server = yield from create_server(self.host.encode(), self.port)
41
+ tg = TaskGroup()
42
+ tg.enter()
43
+ try:
44
+ while True:
45
+ conn = yield from server.accept()
46
+ tg.create_task(self._handle_connection(conn))
47
+ finally:
48
+ yield from tg.exit()
49
+
50
+ def _handle_connection(self, conn: Connection) -> Coro[None]:
51
+ """Handles an incoming connection."""
52
+ try:
53
+ try:
54
+ tls_con = yield from TLSStream.wrap(
55
+ conn, ssl_context=self.ssl_context, standard_compatible=False
56
+ )
57
+ except Exception:
58
+ with move_on_after(3, shield=True):
59
+ yield from conn.close()
60
+ raise
61
+
62
+ buffered_stream = BufferedByteStream(
63
+ receive_stream=tls_con, send_stream=tls_con
64
+ )
65
+
66
+ try:
67
+ request = yield from Request.parse(buffered_stream)
68
+ handler = self.router.resolve(request.method, request.path)
69
+ result = handler(request)
70
+ if isinstance(result, Generator):
71
+ response = yield from result
72
+ else:
73
+ response = result
74
+ yield from buffered_stream.send(response.serialize())
75
+ finally:
76
+ with move_on_after(3, shield=True):
77
+ yield from buffered_stream.close()
78
+ except Exception:
79
+ logger.exception("Connection error")
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from one_ring_http.log import get_logger
8
+ from one_ring_http.response import Response
9
+ from one_ring_loop.fileio import open_file # or however your file I/O is exposed
10
+
11
+ if TYPE_CHECKING:
12
+ from one_ring_http.request import Request
13
+ from one_ring_http.typedef import HTTPHandler
14
+ from one_ring_loop.typedefs import Coro
15
+
16
+ logger = get_logger()
17
+
18
+
19
+ def static_handler(root: str | Path) -> HTTPHandler:
20
+ """Returns a handler that serves files from root directory."""
21
+ root_path = Path(root).resolve()
22
+
23
+ def handler(request: Request) -> Coro[Response]:
24
+ path = request.path
25
+ if path == "/":
26
+ path = "/index.html"
27
+
28
+ candidates = [
29
+ root_path / path.lstrip("/"),
30
+ root_path / (path.lstrip("/") + ".html"),
31
+ root_path / path.lstrip("/") / "index.html",
32
+ ]
33
+
34
+ for candidate in candidates:
35
+ # Resolve and check it's still under root
36
+ file_path = candidate.resolve()
37
+ if not file_path.is_relative_to(root_path) or not file_path.is_file():
38
+ continue
39
+ try:
40
+ file = yield from open_file(file_path)
41
+ except FileNotFoundError:
42
+ continue
43
+
44
+ try:
45
+ body = yield from file.read()
46
+ content_type = (
47
+ mimetypes.guess_type(str(file_path))[0]
48
+ or "application/octet-stream"
49
+ )
50
+ finally:
51
+ yield from file.close()
52
+
53
+ return Response(
54
+ status_code=200,
55
+ headers={"content-type": content_type},
56
+ body=body.encode(),
57
+ )
58
+
59
+ return Response(status_code=404, body=b"Not Found")
60
+
61
+ return handler
@@ -0,0 +1,12 @@
1
+ from collections.abc import Callable
2
+ from typing import Literal
3
+
4
+ from one_ring_http.request import Request
5
+ from one_ring_http.response import Response
6
+ from one_ring_loop.typedefs import Coro
7
+
8
+ type HTTPHeaders = dict[str, str]
9
+
10
+ type HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
11
+
12
+ type HTTPHandler = Callable[[Request], Coro[Response] | Response]
@@ -0,0 +1,37 @@
1
+ """Shared test fixtures for one-ring-http."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from one_ring_http.typedef import HTTPHeaders
8
+ from one_ring_loop.streams.buffered import BufferedByteReceiveStream
9
+ from one_ring_loop.typedefs import Coro
10
+
11
+
12
+ @dataclass(frozen=True, slots=True, kw_only=True)
13
+ class RawHTTPResponse:
14
+ """Parsed raw HTTP response from a buffered stream."""
15
+
16
+ status_code: int
17
+ headers: HTTPHeaders
18
+ body: bytes
19
+
20
+
21
+ def parse_raw_response(stream: BufferedByteReceiveStream) -> Coro[RawHTTPResponse]:
22
+ """Reads and parses a raw HTTP response from a buffered byte stream."""
23
+ status_line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
24
+ status_code = int(status_line.split(b" ", 2)[1])
25
+
26
+ headers: dict[str, str] = {}
27
+ line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
28
+ while line:
29
+ key, val = line.split(b": ", 1)
30
+ headers[key.decode().lower()] = val.decode()
31
+ line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
32
+
33
+ body = b""
34
+ if "content-length" in headers:
35
+ body = yield from stream.receive_exactly(int(headers["content-length"]))
36
+
37
+ return RawHTTPResponse(status_code=status_code, headers=headers, body=body)