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

64 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-05-23 12:21 +0100

1from ._content import Content 

2from ._streams import ByteStream, ClosedStream, Stream 

3from ._headers import Headers 

4from ._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 

80class 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 

134class 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}]>"