Coverage for src/httpx/_headers.py: 93%
74 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-27 16:49 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-27 16:49 +0100
1import typing
4__all__ = ["Headers"]
7VALID_HEADER_CHARS = (
8 "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 "abcdefghijklmnopqrstuvwxyz"
10 "0123456789"
11 "!#$%&'*+-.^_`|~"
12)
15# TODO...
16#
17# * Comma folded values, eg. `Vary: ...`
18# * Multiple Set-Cookie headers.
19# * Non-ascii support.
20# * Ordering, including `Host` header exception.
23def headername(name: str) -> str:
24 if name.strip(VALID_HEADER_CHARS) or not name:
25 raise ValueError("Invalid HTTP header name {key!r}.")
26 return name
29def headervalue(value: str) -> str:
30 value = value.strip(" ")
31 if not value or not value.isascii() or not value.isprintable():
32 raise ValueError("Invalid HTTP header value {key!r}.")
33 return value
36class Headers(typing.Mapping[str, str]):
37 def __init__(
38 self,
39 headers: typing.Mapping[str, str] | typing.Sequence[tuple[str, str]] | None = None,
40 ) -> None:
41 # {'accept': ('Accept', '*/*')}
42 d: dict[str, str] = {}
44 if isinstance(headers, typing.Mapping):
45 # Headers({
46 # 'Content-Length': '1024',
47 # 'Content-Type': 'text/plain; charset=utf-8',
48 # )
49 d = {headername(k): headervalue(v) for k, v in headers.items()}
50 elif headers is not None:
51 # Headers([
52 # ('Location', 'https://www.example.com'),
53 # ('Set-Cookie', 'session_id=3498jj489jhb98jn'),
54 # ])
55 d = {headername(k): headervalue(v) for k, v in headers}
57 self._dict = d
59 def keys(self) -> typing.KeysView[str]:
60 """
61 Return all the header keys.
63 Usage:
65 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
66 assert list(h.keys()) == ["Accept", "User-Agent"]
67 """
68 return self._dict.keys()
70 def values(self) -> typing.ValuesView[str]:
71 """
72 Return all the header values.
74 Usage:
76 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
77 assert list(h.values()) == ["*/*", "python/httpx"]
78 """
79 return self._dict.values()
81 def items(self) -> typing.ItemsView[str, str]:
82 """
83 Return all headers as (key, value) tuples.
85 Usage:
87 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
88 assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")]
89 """
90 return self._dict.items()
92 def get(self, key: str, default: typing.Any = None) -> typing.Any:
93 """
94 Get a value from the query param for a given key. If the key occurs
95 more than once, then only the first value is returned.
97 Usage:
99 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
100 assert h.get("User-Agent") == "python/httpx"
101 """
102 for k, v in self._dict.items():
103 if k.lower() == key.lower():
104 return v
105 return default
107 def copy_set(self, key: str, value: str) -> "Headers":
108 """
109 Return a new Headers instance, setting the value of a key.
111 Usage:
113 h = httpx.Headers({"Expires": "0"})
114 h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")
115 assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})
116 """
117 l = []
118 seen = False
120 # Either insert...
121 for k, v in self._dict.items():
122 if k.lower() == key.lower():
123 l.append((key, value))
124 seen = True
125 else:
126 l.append((k, v))
128 # Or append...
129 if not seen:
130 l.append((key, value))
132 return Headers(l)
134 def copy_remove(self, key: str) -> "Headers":
135 """
136 Return a new Headers instance, removing the value of a key.
138 Usage:
140 h = httpx.Headers({"Accept": "*/*"})
141 h = h.copy_remove("Accept")
142 assert h == httpx.Headers({})
143 """
144 h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}
145 return Headers(h)
147 def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":
148 """
149 Return a new Headers instance, removing the value of a key.
151 Usage:
153 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
154 h = h.copy_update({"Accept-Encoding": "gzip"})
155 assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})
156 """
157 if update is None:
158 return self
160 new = update if isinstance(update, Headers) else Headers(update)
162 # Remove updated items using a case-insensitive approach...
163 keys = set([key.lower() for key in new.keys()])
164 h = {k: v for k, v in self._dict.items() if k.lower() not in keys}
166 # Perform the actual update...
167 h.update(dict(new))
169 return Headers(h)
171 def __getitem__(self, key: str) -> str:
172 match = key.lower()
173 for k, v in self._dict.items():
174 if k.lower() == match:
175 return v
176 raise KeyError(key)
178 def __contains__(self, key: typing.Any) -> bool:
179 match = key.lower()
180 return any(k.lower() == match for k in self._dict.keys())
182 def __iter__(self) -> typing.Iterator[str]:
183 return iter(self.keys())
185 def __len__(self) -> int:
186 return len(self._dict)
188 def __bool__(self) -> bool:
189 return bool(self._dict)
191 def __eq__(self, other: typing.Any) -> bool:
192 self_lower = {k.lower(): v for k, v in self.items()}
193 other_lower = {k.lower(): v for k, v in Headers(other).items()}
194 return self_lower == other_lower
196 def __repr__(self) -> str:
197 return f"<Headers {dict(self)!r}>"
200# def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
201# # The Content-Type header is described in RFC 2616 'Content-Type'
202# # https://datatracker.ietf.org/doc/html/rfc2616#section-14.17
203#
204# # The 'type/subtype; parameter' format is described in RFC 2616 'Media Types'
205# # https://datatracker.ietf.org/doc/html/rfc2616#section-3.7
206#
207# # Parameter quoting is described in RFC 2616 'Transfer Codings'
208# # https://datatracker.ietf.org/doc/html/rfc2616#section-3.6
209#
210# header = header.strip()
211# content_type = ''
212# params = {}
213#
214# # Match the content type (up to the first semicolon or end)
215# match = re.match(r'^([^;]+)', header)
216# if match:
217# content_type = match.group(1).strip().lower()
218# rest = header[match.end():]
219# else:
220# return '', {}
221#
222# # Parse parameters, accounting for quoted strings
223# param_pattern = re.compile(r'''
224# ;\s* # Semicolon + optional whitespace
225# (?P<key>[^=;\s]+) # Parameter key
226# = # Equal sign
227# (?P<value> # Parameter value:
228# "(?:[^"\\]|\\.)*" # Quoted string with escapes
229# | # OR
230# [^;]* # Unquoted string (until semicolon)
231# )
232# ''', re.VERBOSE)
233#
234# for match in param_pattern.finditer(rest):
235# key = match.group('key').lower()
236# value = match.group('value').strip()
237# if value.startswith('"') and value.endswith('"'):
238# # Remove surrounding quotes and unescape
239# value = re.sub(r'\\(.)', r'\1', value[1:-1])
240# params[key] = value