Skip to content

Commit 256075c

Browse files
authored
Merge pull request #1 from modern-python/story/1-2-core-data-types
feat(story-1.2): core data types — Request, Response, Limits, Timeout, ClientConfig
2 parents f5071f9 + 1808165 commit 256075c

11 files changed

Lines changed: 531 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1515
- `SECURITY.md` with 90-day private-disclosure window.
1616
- `CONTRIBUTING.md` with development workflow.
1717
- `CLAUDE.md` with AI-agent guidance.
18+
- Core data types: `Request`, `Response`, `Limits`, `Timeout`, `ClientConfig` — frozen+slotted dataclasses with `with_*` immutability helpers on `Request` and computed `text`/`json()` accessors on `Response` (Story 1.2).
1819

1920
[Unreleased]: https://github.com/modern-python/httpware/compare/HEAD...HEAD

‎docs/stories/1-2-core-data-types.md‎

Lines changed: 232 additions & 0 deletions
Large diffs are not rendered by default.

‎src/httpware/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
"""httpware — resilience-first async HTTP client framework for Python."""
22

3-
__all__: list[str] = []
3+
from httpware.config import ClientConfig, Limits, Timeout
4+
from httpware.request import Request
5+
from httpware.response import Response
6+
7+
8+
__all__ = ["ClientConfig", "Limits", "Request", "Response", "Timeout"]

‎src/httpware/config.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Immutable configuration value types: Limits, Timeout, ClientConfig."""
2+
3+
from collections.abc import Mapping
4+
from dataclasses import dataclass, field
5+
6+
7+
@dataclass(frozen=True, slots=True)
8+
class Timeout:
9+
"""Per-phase request timeout configuration (seconds)."""
10+
11+
connect: float = 5.0
12+
read: float = 30.0
13+
write: float = 30.0
14+
pool: float = 5.0
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class Limits:
19+
"""Connection-pool limits."""
20+
21+
max_connections: int = 100
22+
max_keepalive_connections: int = 20
23+
keepalive_expiry: float = 5.0
24+
25+
26+
@dataclass(frozen=True, slots=True)
27+
class ClientConfig:
28+
"""Immutable client configuration bag."""
29+
30+
base_url: str | None = None
31+
default_headers: Mapping[str, str] = field(default_factory=dict)
32+
default_query: Mapping[str, str] = field(default_factory=dict)
33+
timeout: Timeout = field(default_factory=Timeout)
34+
limits: Limits = field(default_factory=Limits)

‎src/httpware/request.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Immutable request value type."""
2+
3+
import dataclasses
4+
from collections.abc import Mapping
5+
from dataclasses import dataclass, field
6+
from typing import Any, Self
7+
8+
9+
@dataclass(frozen=True, slots=True)
10+
class Request:
11+
"""Immutable HTTP request value type."""
12+
13+
method: str
14+
url: str
15+
headers: Mapping[str, str] = field(default_factory=dict)
16+
params: Mapping[str, str] = field(default_factory=dict)
17+
cookies: Mapping[str, str] = field(default_factory=dict)
18+
body: bytes | None = None
19+
extensions: Mapping[str, Any] = field(default_factory=dict)
20+
21+
def with_header(self, name: str, value: str) -> Self:
22+
"""Return a copy with the given header added or replaced."""
23+
return dataclasses.replace(self, headers={**self.headers, name: value})
24+
25+
def with_url(self, url: str) -> Self:
26+
"""Return a copy with the given URL."""
27+
return dataclasses.replace(self, url=url)
28+
29+
def with_body(self, body: bytes | None) -> Self:
30+
"""Return a copy with the given body."""
31+
return dataclasses.replace(self, body=body)
32+
33+
def with_query(self, params: Mapping[str, str]) -> Self:
34+
"""Return a copy with the given query params replacing the existing ones."""
35+
return dataclasses.replace(self, params=params)

‎src/httpware/response.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Immutable response value type."""
2+
3+
import json
4+
from collections.abc import Mapping
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
9+
_CHARSET_PREFIX = "charset="
10+
11+
12+
def _get_content_type(headers: Mapping[str, str]) -> str:
13+
for key, value in headers.items():
14+
if key.lower() == "content-type":
15+
return value
16+
return ""
17+
18+
19+
def _parse_charset(content_type: str) -> str | None:
20+
for raw in content_type.split(";"):
21+
part = raw.strip()
22+
if part.lower().startswith(_CHARSET_PREFIX):
23+
return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'")
24+
return None
25+
26+
27+
@dataclass(frozen=True, slots=True)
28+
class Response:
29+
"""Immutable HTTP response value type."""
30+
31+
status: int
32+
headers: Mapping[str, str]
33+
content: bytes
34+
url: str
35+
elapsed: float
36+
37+
@property
38+
def text(self) -> str:
39+
"""Decode `content` using the response's declared charset (default UTF-8)."""
40+
charset = _parse_charset(_get_content_type(self.headers)) or "utf-8"
41+
return self.content.decode(charset)
42+
43+
def json(self) -> Any: # noqa: ANN401
44+
"""Parse `content` as JSON."""
45+
return json.loads(self.content)

‎tests/__init__.py‎

Whitespace-only changes.

‎tests/conftest.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Shared pytest fixtures for the httpware test suite."""

‎tests/test_config.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Unit tests for httpware.config types."""
2+
3+
from dataclasses import FrozenInstanceError
4+
5+
import pytest
6+
7+
from httpware import ClientConfig, Limits, Timeout
8+
9+
10+
def test_timeout_defaults() -> None:
11+
assert Timeout() == Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
12+
13+
14+
def test_limits_defaults() -> None:
15+
assert Limits() == Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0)
16+
17+
18+
def test_client_config_defaults() -> None:
19+
cfg = ClientConfig()
20+
assert cfg.base_url is None
21+
assert cfg.default_headers == {}
22+
assert cfg.default_query == {}
23+
assert cfg.timeout == Timeout()
24+
assert cfg.limits == Limits()
25+
26+
27+
def test_client_config_default_mappings_are_independent() -> None:
28+
c1 = ClientConfig()
29+
c2 = ClientConfig()
30+
assert c1.default_headers is not c2.default_headers
31+
assert c1.default_query is not c2.default_query
32+
33+
34+
def test_timeout_is_frozen() -> None:
35+
t = Timeout()
36+
with pytest.raises(FrozenInstanceError):
37+
t.read = 60.0 # ty: ignore[invalid-assignment]
38+
39+
40+
def test_limits_is_frozen() -> None:
41+
lim = Limits()
42+
with pytest.raises(FrozenInstanceError):
43+
lim.max_connections = 50 # ty: ignore[invalid-assignment]
44+
45+
46+
def test_client_config_is_frozen() -> None:
47+
cfg = ClientConfig()
48+
with pytest.raises(FrozenInstanceError):
49+
cfg.base_url = "https://example.com" # ty: ignore[invalid-assignment]

‎tests/test_request.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Unit tests for httpware.request.Request."""
2+
3+
from dataclasses import FrozenInstanceError
4+
5+
import pytest
6+
7+
from httpware import Request
8+
9+
10+
def test_request_is_frozen() -> None:
11+
req = Request(method="GET", url="https://example.com/")
12+
with pytest.raises(FrozenInstanceError):
13+
req.method = "POST" # ty: ignore[invalid-assignment]
14+
15+
16+
def test_request_default_mappings_are_empty_and_independent() -> None:
17+
r1 = Request(method="GET", url="/")
18+
r2 = Request(method="GET", url="/")
19+
assert r1.headers == {}
20+
assert r1.params == {}
21+
assert r1.cookies == {}
22+
assert r1.extensions == {}
23+
assert r1.body is None
24+
assert r1.headers is not r2.headers
25+
26+
27+
def test_request_equality_on_identical_fields() -> None:
28+
r1 = Request(method="GET", url="/x", headers={"a": "1"})
29+
r2 = Request(method="GET", url="/x", headers={"a": "1"})
30+
assert r1 == r2
31+
32+
33+
def test_with_header_adds_when_absent() -> None:
34+
r = Request(method="GET", url="/")
35+
new = r.with_header("X-Trace", "abc")
36+
assert new.headers == {"X-Trace": "abc"}
37+
assert r.headers == {}
38+
assert new is not r
39+
40+
41+
def test_with_header_replaces_when_present() -> None:
42+
r = Request(method="GET", url="/", headers={"X-Trace": "old"})
43+
new = r.with_header("X-Trace", "new")
44+
assert new.headers == {"X-Trace": "new"}
45+
assert r.headers == {"X-Trace": "old"}
46+
47+
48+
def test_with_url_returns_new_instance() -> None:
49+
r = Request(method="GET", url="/a")
50+
new = r.with_url("/b")
51+
assert new.url == "/b"
52+
assert r.url == "/a"
53+
assert new is not r
54+
55+
56+
def test_with_body_returns_new_instance() -> None:
57+
r = Request(method="POST", url="/")
58+
new = r.with_body(b"payload")
59+
assert new.body == b"payload"
60+
assert r.body is None
61+
assert new is not r
62+
63+
64+
def test_with_query_replaces_params() -> None:
65+
r = Request(method="GET", url="/", params={"a": "1"})
66+
new = r.with_query({"b": "2"})
67+
assert new.params == {"b": "2"}
68+
assert r.params == {"a": "1"}
69+
assert new is not r

0 commit comments

Comments
 (0)