Coverage for src/httpx/_models.py: 97%

1 from ._content import Content

2 from ._streams import ByteStream, ClosedStream, Stream

3 from ._headers import Headers

4 from ._urls import URL

5

6 __all__ = [

7 "Response",

8 "Request",

9]

10

11# We're using the same set as stdlib `http.HTTPStatus` here...

12#

13# https://github.com/python/cpython/blob/main/Lib/http/__init__.py

14 _codes = {

15 100: "Continue",

16 101: "Switching Protocols",

17 102: "Processing",

18 103: "Early Hints",

19 200: "OK",

20 201: "Created",

21 202: "Accepted",

22 203: "Non-Authoritative Information",

23 204: "No Content",

24 205: "Reset Content",

25 206: "Partial Content",

26 207: "Multi-Status",

27 208: "Already Reported",

28 226: "IM Used",

29 300: "Multiple Choices",

30 301: "Moved Permanently",

31 302: "Found",

32 303: "See Other",

33 304: "Not Modified",

34 305: "Use Proxy",

35 307: "Temporary Redirect",

36 308: "Permanent Redirect",

37 400: "Bad Request",

38 401: "Unauthorized",

39 402: "Payment Required",

40 403: "Forbidden",

41 404: "Not Found",

42 405: "Method Not Allowed",

43 406: "Not Acceptable",

44 407: "Proxy Authentication Required",

45 408: "Request Timeout",

46 409: "Conflict",

47 410: "Gone",

48 411: "Length Required",

49 412: "Precondition Failed",

50 413: "Content Too Large",

51 414: "URI Too Long",

52 415: "Unsupported Media Type",

53 416: "Range Not Satisfiable",

54 417: "Expectation Failed",

55 418: "I'm a Teapot",

56 421: "Misdirected Request",

57 422: "Unprocessable Content",

58 423: "Locked",

59 424: "Failed Dependency",

60 425: "Too Early",

61 426: "Upgrade Required",

62 428: "Precondition Required",

63 429: "Too Many Requests",

64 431: "Request Header Fields Too Large",

65 451: "Unavailable For Legal Reasons",

66 500: "Internal Server Error",

67 501: "Not Implemented",

68 502: "Bad Gateway",

69 503: "Service Unavailable",

70 504: "Gateway Timeout",

71 505: "HTTP Version Not Supported",

72 506: "Variant Also Negotiates",

73 507: "Insufficient Storage",

74 508: "Loop Detected",

75 510: "Not Extended",

76 511: "Network Authentication Required",

77}

78

79

80 class Request:

81 def __init__(

82 self,

83 method: str,

84 url: URL | str,

85 headers: Headers | dict[str, str] | None = None,

86 content: Content | Stream | bytes | None = None,

87 ):

88 self.method = method

89 self.url = URL(url)

90 self.headers = Headers(headers)

91 self.stream: Stream = ByteStream(b"")

92

93 # https://datatracker.ietf.org/doc/html/rfc2616#section-14.23

94 # RFC 2616, Section 14.23, Host.

95 #

96 # A client MUST include a Host header field in all HTTP/1.1 request messages.

97 self.headers = self.headers.copy_setdefault("Host", self.url.netloc)

98

99 if content is not None:

100 if isinstance(content, bytes):

101 self.stream = ByteStream(content)

102 elif isinstance(content, Stream):

103 self.stream = content

104 elif isinstance(content, Content):

105 assert isinstance(content, Content)

106 # Eg. Request("POST", "https://www.example.com", content=Form(...))

107 stream, content_type = content.encode()

108 self.headers = self.headers.copy_set("Content-Type", content_type)

109 self.stream = stream

110

111 # https://datatracker.ietf.org/doc/html/rfc2616#section-4.3

112 # RFC 2616, Section 4.3, Message Body.

113 #

114 # The presence of a message-body in a request is signaled by the

115 # inclusion of a Content-Length or Transfer-Encoding header field in

116 # the request's message-headers.

117 content_length: int | None = self.stream.size

118 if content_length is None:

119 self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")

120 elif content_length > 0:

121 self.headers = self.headers.copy_set("Content-Length", str(content_length))

122

123 def read(self):

124 self.body = b"".join([part for part in self.stream])

125 self.stream = ByteStream(self.body)

126

127 def close(self):

128 self.stream = ClosedStream()

129

130 def __repr__(self):

131 return f"<Request [{self.method} {str(self.url)!r}]>"

132

133

134 class Response:

135 def __init__(

136 self,

137 code: int,

138 *,

139 headers: Headers | dict[str, str] | None = None,

140 content: Content | Stream | bytes | None = None,

141 ):

142 self.code = code

143 self.headers = Headers(headers)

144 self.stream : Stream = ByteStream(b"")

145

146 if content is not None:

147 if isinstance(content, bytes):

148 self.stream = ByteStream(content)

149 elif isinstance(content, Stream):

150 self.stream = content

151 elif isinstance(content, Content):

152 # Eg. Response(200, content=HTML(...))

153 stream, content_type = content.encode()

154 self.headers = self.headers.copy_set("Content-Type", content_type)

155 self.stream = stream

156

157 # https://datatracker.ietf.org/doc/html/rfc2616#section-4.3

158 # RFC 2616, Section 4.3, Message Body.

159 #

160 # All 1xx (informational), 204 (no content), and 304 (not modified) responses

161 # MUST NOT include a message-body. All other responses do include a

162 # message-body, although it MAY be of zero length.

163 if code >= 200 and code != 204 and code != 304:

164 content_length: int | None = self.stream.size

165 if content_length is None:

166 self.headers = self.headers.copy_set("Transfer-Encoding", "chunked")

167 else:

168 self.headers = self.headers.copy_set("Content-Length", str(content_length))

169

170 @property

171 def reason_phrase(self):

172 return _codes.get(self.code, "Unknown Status Code")

173

174 def read(self):

175 self.body = b"".join([part for part in self.stream])

176 self.stream = ByteStream(self.body)

177

178 def close(self):

179 self.stream = ClosedStream()

180

181 def __repr__(self):

182 return f"<Response [{self.code} {self.reason_phrase}]>"