Skip to content

Commit 3c4418b

Browse files
authored
Merge pull request #39 from python-hyper/query_value_encoding
Allow unescaped equal signs in query parameter values
2 parents c19cf48 + ff24885 commit 3c4418b

File tree

4 files changed

+55
-22
lines changed

4 files changed

+55
-22
lines changed

.tox-coveragerc

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ branch = True
33
source =
44
hyperlink
55
../hyperlink
6+
omit =
7+
*/flycheck_*
68

79
[paths]
810
source =

hyperlink/_url.py

+44-19
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,10 @@ def __nonzero__(self):
169169
_SCHEMELESS_PATH_DELIMS = _ALL_DELIMS - _SCHEMELESS_PATH_SAFE
170170
_FRAGMENT_SAFE = _UNRESERVED_CHARS | _PATH_SAFE | set(u'/?')
171171
_FRAGMENT_DELIMS = _ALL_DELIMS - _FRAGMENT_SAFE
172-
_QUERY_SAFE = _UNRESERVED_CHARS | _FRAGMENT_SAFE - set(u'&=+')
173-
_QUERY_DELIMS = _ALL_DELIMS - _QUERY_SAFE
172+
_QUERY_VALUE_SAFE = _UNRESERVED_CHARS | _FRAGMENT_SAFE - set(u'&+')
173+
_QUERY_VALUE_DELIMS = _ALL_DELIMS - _QUERY_VALUE_SAFE
174+
_QUERY_KEY_SAFE = _UNRESERVED_CHARS | _QUERY_VALUE_SAFE - set(u'=')
175+
_QUERY_KEY_DELIMS = _ALL_DELIMS - _QUERY_KEY_SAFE
174176

175177

176178
def _make_decode_map(delims, allow_percent=False):
@@ -204,8 +206,10 @@ def _make_quote_map(safe_chars):
204206
_PATH_PART_QUOTE_MAP = _make_quote_map(_PATH_SAFE)
205207
_SCHEMELESS_PATH_PART_QUOTE_MAP = _make_quote_map(_SCHEMELESS_PATH_SAFE)
206208
_PATH_DECODE_MAP = _make_decode_map(_PATH_DELIMS)
207-
_QUERY_PART_QUOTE_MAP = _make_quote_map(_QUERY_SAFE)
208-
_QUERY_DECODE_MAP = _make_decode_map(_QUERY_DELIMS)
209+
_QUERY_KEY_QUOTE_MAP = _make_quote_map(_QUERY_KEY_SAFE)
210+
_QUERY_KEY_DECODE_MAP = _make_decode_map(_QUERY_KEY_DELIMS)
211+
_QUERY_VALUE_QUOTE_MAP = _make_quote_map(_QUERY_VALUE_SAFE)
212+
_QUERY_VALUE_DECODE_MAP = _make_decode_map(_QUERY_VALUE_DELIMS)
209213
_FRAGMENT_QUOTE_MAP = _make_quote_map(_FRAGMENT_SAFE)
210214
_FRAGMENT_DECODE_MAP = _make_decode_map(_FRAGMENT_DELIMS)
211215
_UNRESERVED_QUOTE_MAP = _make_quote_map(_UNRESERVED_CHARS)
@@ -290,17 +294,28 @@ def _encode_path_parts(text_parts, rooted=False, has_scheme=True,
290294
return tuple(encoded_parts)
291295

292296

293-
def _encode_query_part(text, maximal=True):
297+
def _encode_query_key(text, maximal=True):
294298
"""
295299
Percent-encode a single query string key or value.
296300
"""
297301
if maximal:
298302
bytestr = normalize('NFC', text).encode('utf8')
299-
return u''.join([_QUERY_PART_QUOTE_MAP[b] for b in bytestr])
300-
return u''.join([_QUERY_PART_QUOTE_MAP[t] if t in _QUERY_DELIMS else t
303+
return u''.join([_QUERY_KEY_QUOTE_MAP[b] for b in bytestr])
304+
return u''.join([_QUERY_KEY_QUOTE_MAP[t] if t in _QUERY_KEY_DELIMS else t
301305
for t in text])
302306

303307

308+
def _encode_query_value(text, maximal=True):
309+
"""
310+
Percent-encode a single query string key or value.
311+
"""
312+
if maximal:
313+
bytestr = normalize('NFC', text).encode('utf8')
314+
return u''.join([_QUERY_VALUE_QUOTE_MAP[b] for b in bytestr])
315+
return u''.join([_QUERY_VALUE_QUOTE_MAP[t]
316+
if t in _QUERY_VALUE_DELIMS else t for t in text])
317+
318+
304319
def _encode_fragment_part(text, maximal=True):
305320
"""Quote the fragment part of the URL. Fragments don't have
306321
subdelimiters, so the whole URL fragment can be passed.
@@ -498,10 +513,16 @@ def _decode_path_part(text, normalize_case=False, encode_stray_percents=False):
498513
_decode_map=_PATH_DECODE_MAP)
499514

500515

501-
def _decode_query_part(text, normalize_case=False, encode_stray_percents=False):
516+
def _decode_query_key(text, normalize_case=False, encode_stray_percents=False):
517+
return _percent_decode(text, normalize_case=normalize_case,
518+
encode_stray_percents=encode_stray_percents,
519+
_decode_map=_QUERY_KEY_DECODE_MAP)
520+
521+
522+
def _decode_query_value(text, normalize_case=False, encode_stray_percents=False):
502523
return _percent_decode(text, normalize_case=normalize_case,
503524
encode_stray_percents=encode_stray_percents,
504-
_decode_map=_QUERY_DECODE_MAP)
525+
_decode_map=_QUERY_VALUE_DECODE_MAP)
505526

506527

507528
def _decode_fragment_part(text, normalize_case=False, encode_stray_percents=False):
@@ -1341,9 +1362,9 @@ def to_uri(self):
13411362
userinfo=new_userinfo,
13421363
host=new_host,
13431364
path=new_path,
1344-
query=tuple([tuple(_encode_query_part(x, maximal=True)
1345-
if x is not None else None
1346-
for x in (k, v))
1365+
query=tuple([(_encode_query_key(k, maximal=True),
1366+
_encode_query_value(v, maximal=True)
1367+
if v is not None else None)
13471368
for k, v in self.query]),
13481369
fragment=_encode_fragment_part(self.fragment, maximal=True)
13491370
)
@@ -1379,9 +1400,9 @@ def to_iri(self):
13791400
host=host_text,
13801401
path=[_decode_path_part(segment)
13811402
for segment in self.path],
1382-
query=[tuple(_decode_query_part(x)
1383-
if x is not None else None
1384-
for x in (k, v))
1403+
query=[(_decode_query_key(k),
1404+
_decode_query_value(v)
1405+
if v is not None else None)
13851406
for k, v in self.query],
13861407
fragment=_decode_fragment_part(self.fragment))
13871408

@@ -1417,10 +1438,14 @@ def to_text(self, with_password=False):
14171438
has_scheme=bool(scheme),
14181439
has_authority=bool(authority),
14191440
maximal=False)
1420-
query_string = u'&'.join(
1421-
u'='.join((_encode_query_part(x, maximal=False)
1422-
for x in ([k] if v is None else [k, v])))
1423-
for (k, v) in self.query)
1441+
query_parts = []
1442+
for k, v in self.query:
1443+
if v is None:
1444+
query_parts.append(_encode_query_key(k, maximal=False))
1445+
else:
1446+
query_parts.append(u'='.join((_encode_query_key(k, maximal=False),
1447+
_encode_query_value(v, maximal=False))))
1448+
query_string = u'&'.join(query_parts)
14241449

14251450
fragment = self.fragment
14261451

hyperlink/test/test_url.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -542,10 +542,16 @@ def test_parseEqualSignInParamValue(self):
542542
"""
543543
u = URL.from_text('http://localhost/?=x=x=x')
544544
self.assertEqual(u.get(''), ['x=x=x'])
545-
self.assertEqual(u.to_text(), 'http://localhost/?=x%3Dx%3Dx')
545+
self.assertEqual(u.to_text(), 'http://localhost/?=x=x=x')
546546
u = URL.from_text('http://localhost/?foo=x=x=x&bar=y')
547547
self.assertEqual(u.query, (('foo', 'x=x=x'), ('bar', 'y')))
548-
self.assertEqual(u.to_text(), 'http://localhost/?foo=x%3Dx%3Dx&bar=y')
548+
self.assertEqual(u.to_text(), 'http://localhost/?foo=x=x=x&bar=y')
549+
550+
u = URL.from_text('https://example.com/?argument=3&argument=4&operator=%3D')
551+
iri = u.to_iri()
552+
self.assertEqual(iri.get('operator'), ['='])
553+
# assert that the equals is not unnecessarily escaped
554+
self.assertEqual(iri.to_uri().get('operator'), ['='])
549555

550556
def test_empty(self):
551557
"""

tox.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ envlist = py26,py27,py34,py35,py36,pypy,coverage-report,packaging
44
[testenv]
55
changedir = .tox
66
deps = -rrequirements-test.txt
7-
commands = coverage run --parallel --omit 'flycheck__*' --rcfile {toxinidir}/.tox-coveragerc -m pytest --doctest-modules {envsitepackagesdir}/hyperlink {posargs}
7+
commands = coverage run --parallel --rcfile {toxinidir}/.tox-coveragerc -m pytest --doctest-modules {envsitepackagesdir}/hyperlink {posargs}
88

99
# Uses default basepython otherwise reporting doesn't work on Travis where
1010
# Python 3.6 is only available in 3.6 jobs.

0 commit comments

Comments
 (0)