Skip to content
This repository was archived by the owner on Jan 13, 2021. It is now read-only.

Commit 0ac6123

Browse files
committed
Initial header set implementation.
1 parent 4bb3a89 commit 0ac6123

File tree

3 files changed

+355
-0
lines changed

3 files changed

+355
-0
lines changed

hyper/common/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
hyper/common
4+
~~~~~~~~~~~~
5+
6+
Common code in hyper.
7+
"""

hyper/common/headers.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
hyper/common/headers
4+
~~~~~~~~~~~~~~~~~~~~~
5+
6+
Contains hyper's structures for storing and working with HTTP headers.
7+
"""
8+
import collections
9+
10+
11+
class HTTPHeaderMap(collections.MutableMapping):
12+
"""
13+
A structure that contains HTTP headers.
14+
15+
HTTP headers are a curious beast. At the surface level they look roughly
16+
like a name-value set, but in practice they have many variations that
17+
make them tricky:
18+
19+
- duplicate keys are allowed
20+
- keys are compared case-insensitively
21+
- duplicate keys are isomorphic to comma-separated values, *except when
22+
they aren't*!
23+
- they logically contain a form of ordering
24+
25+
This data structure is an attempt to preserve all of that information
26+
while being as user-friendly as possible.
27+
"""
28+
def __init__(self, *args, **kwargs):
29+
# The meat of the structure. In practice, headers are an ordered list
30+
# of tuples. This early version of the data structure simply uses this
31+
# directly under the covers.
32+
self._items = []
33+
34+
for arg in args:
35+
for item in arg:
36+
self._items.extend(canonical_form(*item))
37+
38+
for k, v in kwargs.items():
39+
self._items.extend(canonical_form(k, v))
40+
41+
def __getitem__(self, key):
42+
"""
43+
Unlike the dict __getitem__, this returns a list of items in the order
44+
they were added.
45+
"""
46+
values = []
47+
48+
for k, v in self._items:
49+
if _keys_equal(k, key):
50+
values.append(v)
51+
52+
if not values:
53+
raise KeyError()
54+
55+
return values
56+
57+
def __setitem__(self, key, value):
58+
"""
59+
Unlike the dict __setitem__, this appends to the list of items. It also
60+
splits out headers that can be split on the comma.
61+
"""
62+
self._items.extend(canonical_form(key, value))
63+
64+
def __delitem__(self, key):
65+
"""
66+
Sadly, __delitem__ is kind of stupid here, but the best we can do is
67+
delete all headers with a given key. To correctly achieve the 'KeyError
68+
on missing key' logic from dictionaries, we need to do this slowly.
69+
"""
70+
indices = []
71+
for (i, (k, v)) in enumerate(self._items):
72+
if _keys_equal(k, key):
73+
indices.append(i)
74+
75+
if not indices:
76+
raise KeyError()
77+
78+
for i in indices[::-1]:
79+
self._items.pop(i)
80+
81+
def __iter__(self):
82+
"""
83+
This mapping iterates like the list of tuples it is.
84+
"""
85+
for pair in self._items:
86+
yield pair
87+
88+
def __len__(self):
89+
"""
90+
The length of this mapping is the number of individual headers.
91+
"""
92+
return len(self._items)
93+
94+
def __contains__(self, key):
95+
"""
96+
If any header is present with this key, returns True.
97+
"""
98+
return any(_keys_equal(key, k) for k, _ in self._items)
99+
100+
def keys(self):
101+
"""
102+
Returns an iterable of the header keys in the mapping. This explicitly
103+
does not filter duplicates, ensuring that it's the same length as
104+
len().
105+
"""
106+
for n, _ in self._items:
107+
yield n
108+
109+
def items(self):
110+
"""
111+
This mapping iterates like the list of tuples it is.
112+
"""
113+
for item in self:
114+
yield item
115+
116+
def values(self):
117+
"""
118+
This is an almost nonsensical query on a header dictionary, but we
119+
satisfy it in the exact same way we satisfy 'keys'.
120+
"""
121+
for _, v in self._items:
122+
yield v
123+
124+
def get(self, name, default=None):
125+
"""
126+
Unlike the dict get, this returns a list of items in the order
127+
they were added.
128+
"""
129+
try:
130+
return self[name]
131+
except KeyError:
132+
return default
133+
134+
def __eq__(self, other):
135+
return self._items == other._items
136+
137+
def __ne__(self, other):
138+
return self._items != other._items
139+
140+
141+
def canonical_form(k, v):
142+
"""
143+
Returns an iterable of key-value-pairs corresponding to the header in
144+
canonical form. This means that the header is split on commas unless for
145+
any reason it's a super-special snowflake (I'm looking at you Set-Cookie).
146+
"""
147+
SPECIAL_SNOWFLAKES = set(['set-cookie', 'set-cookie2'])
148+
149+
k = k.lower()
150+
151+
if k in SPECIAL_SNOWFLAKES:
152+
yield k, v
153+
else:
154+
for sub_val in v.split(','):
155+
yield k, sub_val.strip()
156+
157+
def _keys_equal(x, y):
158+
"""
159+
Returns 'True' if the two keys are equal by the laws of HTTP headers.
160+
"""
161+
return x.lower() == y.lower()

test/test_headers.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
from hyper.common.headers import HTTPHeaderMap
2+
3+
import pytest
4+
5+
class TestHTTPHeaderMap(object):
6+
def test_header_map_can_insert_single_header(self):
7+
h = HTTPHeaderMap()
8+
h['key'] = 'value'
9+
assert h['key'] == ['value']
10+
11+
def test_header_map_insensitive_key(self):
12+
h = HTTPHeaderMap()
13+
h['KEY'] = 'value'
14+
assert h['key'] == ['value']
15+
16+
def test_header_map_is_iterable_in_order(self):
17+
h = HTTPHeaderMap()
18+
items = [
19+
('k1', 'v2'),
20+
('k2', 'v2'),
21+
('k2', 'v3'),
22+
]
23+
24+
for k, v in items:
25+
h[k] = v
26+
27+
for i, pair in enumerate(h):
28+
assert items[i] == pair
29+
30+
def test_header_map_allows_multiple_values(self):
31+
h = HTTPHeaderMap()
32+
h['key'] = 'v1'
33+
h['Key'] = 'v2'
34+
35+
assert h['key'] == ['v1', 'v2']
36+
37+
def test_header_map_can_delete_value(self):
38+
h = HTTPHeaderMap()
39+
h['key'] = 'v1'
40+
del h['key']
41+
42+
with pytest.raises(KeyError):
43+
h['key']
44+
45+
def test_header_map_deletes_all_values(self):
46+
h = HTTPHeaderMap()
47+
h['key'] = 'v1'
48+
h['key'] = 'v2'
49+
del h['key']
50+
51+
with pytest.raises(KeyError):
52+
h['key']
53+
54+
def test_setting_comma_separated_header(self):
55+
h = HTTPHeaderMap()
56+
h['key'] = 'v1, v2'
57+
58+
assert h['key'] == ['v1', 'v2']
59+
60+
def test_containment(self):
61+
h = HTTPHeaderMap()
62+
h['key'] = 'val'
63+
64+
assert 'key' in h
65+
assert 'nonkey' not in h
66+
67+
def test_length_counts_lines_separately(self):
68+
h = HTTPHeaderMap()
69+
h['k1'] = 'v1, v2'
70+
h['k2'] = 'v3'
71+
h['k1'] = 'v4'
72+
73+
assert len(h) == 4
74+
75+
def test_keys(self):
76+
h = HTTPHeaderMap()
77+
h['k1'] = 'v1, v2'
78+
h['k2'] = 'v3'
79+
h['k1'] = 'v4'
80+
81+
assert len(list(h.keys())) == 4
82+
assert list(h.keys()) == ['k1', 'k1', 'k2', 'k1']
83+
84+
def test_values(self):
85+
h = HTTPHeaderMap()
86+
h['k1'] = 'v1, v2'
87+
h['k2'] = 'v3'
88+
h['k1'] = 'v4'
89+
90+
assert len(list(h.values())) == 4
91+
assert list(h.values()) == ['v1', 'v2', 'v3', 'v4']
92+
93+
def test_items(self):
94+
h = HTTPHeaderMap()
95+
items = [
96+
('k1', 'v2'),
97+
('k2', 'v2'),
98+
('k2', 'v3'),
99+
]
100+
101+
for k, v in items:
102+
h[k] = v
103+
104+
for i, pair in enumerate(h.items()):
105+
assert items[i] == pair
106+
107+
def test_empty_get(self):
108+
h = HTTPHeaderMap()
109+
assert h.get('nonexistent', 'hi there') == 'hi there'
110+
111+
def test_actual_get(self):
112+
h = HTTPHeaderMap()
113+
h['k1'] = 'v1, v2'
114+
h['k2'] = 'v3'
115+
h['k1'] = 'v4'
116+
117+
assert h.get('k1') == ['v1', 'v2', 'v4']
118+
119+
def test_doesnt_split_set_cookie(self):
120+
h = HTTPHeaderMap()
121+
h['Set-Cookie'] = 'v1, v2'
122+
assert h['set-cookie'] == ['v1, v2']
123+
assert h.get('set-cookie') == ['v1, v2']
124+
125+
def test_equality(self):
126+
h1 = HTTPHeaderMap()
127+
h1['k1'] = 'v1, v2'
128+
h1['k2'] = 'v3'
129+
h1['k1'] = 'v4'
130+
131+
h2 = HTTPHeaderMap()
132+
h2['k1'] = 'v1'
133+
h2['k1'] = 'v2'
134+
h2['k2'] = 'v3'
135+
h2['k1'] = 'v4'
136+
137+
assert h1 == h2
138+
139+
def test_inequality(self):
140+
h1 = HTTPHeaderMap()
141+
h1['k1'] = 'v1, v2'
142+
h1['k2'] = 'v3'
143+
h1['k1'] = 'v4'
144+
145+
h2 = HTTPHeaderMap()
146+
h2['k1'] = 'v1'
147+
h2['k1'] = 'v4'
148+
h2['k1'] = 'v2'
149+
h2['k2'] = 'v3'
150+
151+
assert h1 != h2
152+
153+
def test_deleting_nonexistent(self):
154+
h = HTTPHeaderMap()
155+
156+
with pytest.raises(KeyError):
157+
del h['key']
158+
159+
def test_can_create_from_iterable(self):
160+
items = [
161+
('k1', 'v2'),
162+
('k2', 'v2'),
163+
('k2', 'v3'),
164+
]
165+
h = HTTPHeaderMap(items)
166+
167+
assert list(h) == items
168+
169+
def test_can_create_from_multiple_iterables(self):
170+
items = [
171+
('k1', 'v2'),
172+
('k2', 'v2'),
173+
('k2', 'v3'),
174+
]
175+
h = HTTPHeaderMap(items, items, items)
176+
177+
assert list(h) == items + items + items
178+
179+
def test_create_from_iterables_and_kwargs(self):
180+
items = [
181+
('k1', 'v2'),
182+
('k2', 'v2'),
183+
('k2', 'v3'),
184+
]
185+
h = HTTPHeaderMap(items, k3='v4', k4='v5')
186+
187+
assert list(h) == items + [('k3', 'v4'), ('k4', 'v5')]

0 commit comments

Comments
 (0)