Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `SECURITY.md` with 90-day private-disclosure window.
- `CONTRIBUTING.md` with development workflow.
- `CLAUDE.md` with AI-agent guidance.
- 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).

[Unreleased]: https://github.com/modern-python/httpware/compare/HEAD...HEAD
232 changes: 232 additions & 0 deletions docs/stories/1-2-core-data-types.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/httpware/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""httpware — resilience-first async HTTP client framework for Python."""

__all__: list[str] = []
from httpware.config import ClientConfig, Limits, Timeout
from httpware.request import Request
from httpware.response import Response


__all__ = ["ClientConfig", "Limits", "Request", "Response", "Timeout"]
34 changes: 34 additions & 0 deletions src/httpware/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Immutable configuration value types: Limits, Timeout, ClientConfig."""

from collections.abc import Mapping
from dataclasses import dataclass, field


@dataclass(frozen=True, slots=True)
class Timeout:
"""Per-phase request timeout configuration (seconds)."""

connect: float = 5.0
read: float = 30.0
write: float = 30.0
pool: float = 5.0


@dataclass(frozen=True, slots=True)
class Limits:
"""Connection-pool limits."""

max_connections: int = 100
max_keepalive_connections: int = 20
keepalive_expiry: float = 5.0


@dataclass(frozen=True, slots=True)
class ClientConfig:
"""Immutable client configuration bag."""

base_url: str | None = None
default_headers: Mapping[str, str] = field(default_factory=dict)
default_query: Mapping[str, str] = field(default_factory=dict)
timeout: Timeout = field(default_factory=Timeout)
limits: Limits = field(default_factory=Limits)
35 changes: 35 additions & 0 deletions src/httpware/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Immutable request value type."""

import dataclasses
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Self


@dataclass(frozen=True, slots=True)
class Request:
"""Immutable HTTP request value type."""

method: str
url: str
headers: Mapping[str, str] = field(default_factory=dict)
params: Mapping[str, str] = field(default_factory=dict)
cookies: Mapping[str, str] = field(default_factory=dict)
body: bytes | None = None
extensions: Mapping[str, Any] = field(default_factory=dict)

def with_header(self, name: str, value: str) -> Self:
"""Return a copy with the given header added or replaced."""
return dataclasses.replace(self, headers={**self.headers, name: value})

def with_url(self, url: str) -> Self:
"""Return a copy with the given URL."""
return dataclasses.replace(self, url=url)

def with_body(self, body: bytes | None) -> Self:
"""Return a copy with the given body."""
return dataclasses.replace(self, body=body)

def with_query(self, params: Mapping[str, str]) -> Self:
"""Return a copy with the given query params replacing the existing ones."""
return dataclasses.replace(self, params=params)
45 changes: 45 additions & 0 deletions src/httpware/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Immutable response value type."""

import json
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any


_CHARSET_PREFIX = "charset="


def _get_content_type(headers: Mapping[str, str]) -> str:
for key, value in headers.items():
if key.lower() == "content-type":
return value
return ""


def _parse_charset(content_type: str) -> str | None:
for raw in content_type.split(";"):
part = raw.strip()
if part.lower().startswith(_CHARSET_PREFIX):
return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'")
return None


@dataclass(frozen=True, slots=True)
class Response:
"""Immutable HTTP response value type."""

status: int
headers: Mapping[str, str]
content: bytes
url: str
elapsed: float

@property
def text(self) -> str:
"""Decode `content` using the response's declared charset (default UTF-8)."""
charset = _parse_charset(_get_content_type(self.headers)) or "utf-8"
return self.content.decode(charset)

def json(self) -> Any: # noqa: ANN401
"""Parse `content` as JSON."""
return json.loads(self.content)
Empty file added tests/__init__.py
Empty file.
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Shared pytest fixtures for the httpware test suite."""
49 changes: 49 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Unit tests for httpware.config types."""

from dataclasses import FrozenInstanceError

import pytest

from httpware import ClientConfig, Limits, Timeout


def test_timeout_defaults() -> None:
assert Timeout() == Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)


def test_limits_defaults() -> None:
assert Limits() == Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0)


def test_client_config_defaults() -> None:
cfg = ClientConfig()
assert cfg.base_url is None
assert cfg.default_headers == {}
assert cfg.default_query == {}
assert cfg.timeout == Timeout()
assert cfg.limits == Limits()


def test_client_config_default_mappings_are_independent() -> None:
c1 = ClientConfig()
c2 = ClientConfig()
assert c1.default_headers is not c2.default_headers
assert c1.default_query is not c2.default_query


def test_timeout_is_frozen() -> None:
t = Timeout()
with pytest.raises(FrozenInstanceError):
t.read = 60.0 # ty: ignore[invalid-assignment]


def test_limits_is_frozen() -> None:
lim = Limits()
with pytest.raises(FrozenInstanceError):
lim.max_connections = 50 # ty: ignore[invalid-assignment]


def test_client_config_is_frozen() -> None:
cfg = ClientConfig()
with pytest.raises(FrozenInstanceError):
cfg.base_url = "https://example.com" # ty: ignore[invalid-assignment]
69 changes: 69 additions & 0 deletions tests/test_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Unit tests for httpware.request.Request."""

from dataclasses import FrozenInstanceError

import pytest

from httpware import Request


def test_request_is_frozen() -> None:
req = Request(method="GET", url="https://example.com/")
with pytest.raises(FrozenInstanceError):
req.method = "POST" # ty: ignore[invalid-assignment]


def test_request_default_mappings_are_empty_and_independent() -> None:
r1 = Request(method="GET", url="/")
r2 = Request(method="GET", url="/")
assert r1.headers == {}
assert r1.params == {}
assert r1.cookies == {}
assert r1.extensions == {}
assert r1.body is None
assert r1.headers is not r2.headers


def test_request_equality_on_identical_fields() -> None:
r1 = Request(method="GET", url="/x", headers={"a": "1"})
r2 = Request(method="GET", url="/x", headers={"a": "1"})
assert r1 == r2


def test_with_header_adds_when_absent() -> None:
r = Request(method="GET", url="/")
new = r.with_header("X-Trace", "abc")
assert new.headers == {"X-Trace": "abc"}
assert r.headers == {}
assert new is not r


def test_with_header_replaces_when_present() -> None:
r = Request(method="GET", url="/", headers={"X-Trace": "old"})
new = r.with_header("X-Trace", "new")
assert new.headers == {"X-Trace": "new"}
assert r.headers == {"X-Trace": "old"}


def test_with_url_returns_new_instance() -> None:
r = Request(method="GET", url="/a")
new = r.with_url("/b")
assert new.url == "/b"
assert r.url == "/a"
assert new is not r


def test_with_body_returns_new_instance() -> None:
r = Request(method="POST", url="/")
new = r.with_body(b"payload")
assert new.body == b"payload"
assert r.body is None
assert new is not r


def test_with_query_replaces_params() -> None:
r = Request(method="GET", url="/", params={"a": "1"})
new = r.with_query({"b": "2"})
assert new.params == {"b": "2"}
assert r.params == {"a": "1"}
assert new is not r
59 changes: 59 additions & 0 deletions tests/test_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Unit tests for httpware.response.Response."""

from dataclasses import FrozenInstanceError

import pytest

from httpware import Response


def test_response_is_frozen() -> None:
resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)
with pytest.raises(FrozenInstanceError):
resp.status = 500 # ty: ignore[invalid-assignment]


def test_response_text_defaults_to_utf8() -> None:
resp = Response(status=200, headers={}, content=b"hello", url="/", elapsed=0.0)
assert resp.text == "hello"


def test_response_text_decodes_unicode_default() -> None:
body = "café".encode()
resp = Response(status=200, headers={}, content=body, url="/", elapsed=0.0)
assert resp.text == "café"


@pytest.mark.parametrize("header_name", ["content-type", "Content-Type", "CONTENT-TYPE"])
def test_response_text_honors_explicit_charset(header_name: str) -> None:
body = "café".encode("latin-1")
resp = Response(
status=200,
headers={header_name: "text/plain; charset=latin-1"},
content=body,
url="/",
elapsed=0.0,
)
assert resp.text == "café"


def test_response_text_falls_back_to_utf8_on_missing_charset() -> None:
resp = Response(
status=200,
headers={"content-type": "application/json"},
content=b'{"x": 1}',
url="/",
elapsed=0.0,
)
assert resp.text == '{"x": 1}'


def test_response_json_parses_body() -> None:
resp = Response(status=200, headers={}, content=b'{"a": 1, "b": [2, 3]}', url="/", elapsed=0.0)
assert resp.json() == {"a": 1, "b": [2, 3]}


def test_response_equality_on_identical_fields() -> None:
r1 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5)
r2 = Response(status=200, headers={"a": "1"}, content=b"x", url="/", elapsed=0.5)
assert r1 == r2
Loading