Skip to content

Commit

Permalink
Allow Range and Content-Range headers to contain any valid range unit
Browse files Browse the repository at this point in the history
- Range units other than 'bytes' may be provided as part of a Range
  or Content-Range header
- Updated __repr__() tests for Range and ContentRange to actually test
  the return value of the repr call (previous test was only asserting
  that __repr__() returned a 'truthy' value)
- Updated Range repr to return a string representation enclosed in angle
  brackets, matching the style of most other WebOb classes
  (vs. a string that may be able to be passed to `eval`)
- Fixes Pylons#177
  • Loading branch information
ltvolks committed Nov 25, 2014
1 parent 552743d commit bcb4e5a
Show file tree
Hide file tree
Showing 2 changed files with 37 additions and 17 deletions.
16 changes: 14 additions & 2 deletions tests/test_byterange.py
Expand Up @@ -17,9 +17,14 @@ def test_range_parse():
assert isinstance(Range.parse('BYTES=0-99'), Range)
assert isinstance(Range.parse('bytes = 0-99'), Range)
assert isinstance(Range.parse('bytes=0 - 102'), Range)
assert isinstance(Range.parse('items=0-99'), Range)
assert isinstance(Range.parse('ITEMS=0-99'), Range)

def test_range_parse_invalid_ranges():
assert Range.parse('bytes=10-5') is None
assert Range.parse('bytes 5-10') is None
assert Range.parse('words=10-5') is None
assert Range.parse('items extra=10-5') is None

def test_range_content_range_length_none():
range = Range(0, 100)
Expand Down Expand Up @@ -54,10 +59,14 @@ def test_range_str_end_none_negative_start():
def test_range_str_1():
range = Range(0, 100)
eq_(str(range), 'bytes=0-99')
range = Range(0, 100, 'items')
eq_(str(range), 'items=0-99')

def test_range_repr():
range = Range(0, 99)
assert_true(range.__repr__(), '<Range bytes 0-98>')
eq_(range.__repr__(), '<Range bytes=0-98>')
range = Range(0, 99, 'items')
eq_(range.__repr__(), '<Range items=0-98>')


# ContentRange class
Expand All @@ -67,13 +76,16 @@ def test_contentrange_bad_input():

def test_contentrange_repr():
contentrange = ContentRange(0, 99, 100)
assert_true(repr(contentrange), '<ContentRange bytes 0-98/100>')
eq_(repr(contentrange), '<ContentRange bytes 0-98/100>')

def test_contentrange_str():
contentrange = ContentRange(0, 99, None)
eq_(str(contentrange), 'bytes 0-98/*')
contentrange = ContentRange(None, None, 100)
eq_(str(contentrange), 'bytes */100')
contentrange = ContentRange(0, 19, 100, 'items')
eq_(str(contentrange), 'items 0-18/100')


def test_contentrange_iter():
contentrange = ContentRange(0, 99, 100)
Expand Down
38 changes: 23 additions & 15 deletions webob/byterange.py
Expand Up @@ -2,18 +2,27 @@

__all__ = ['Range', 'ContentRange']

_rx_range = re.compile('bytes *= *(\d*) *- *(\d*)', flags=re.I)
_rx_content_range = re.compile(r'bytes (?:(\d+)-(\d+)|[*])/(?:(\d+)|[*])')
# Range-Unit tokens are defined as 1 or or US-ASCII chars 0-127, excluding
# control chars or separators. http://tools.ietf.org/html/rfc2616#section-2.2
seps = r'()<>@,;:\"/[]?={} '
CTLs = ''.join([chr(i) for i in range(0,32) + [127]])
excluded = re.escape(CTLs + seps)

_rx_range = re.compile('([^%s]+) *= *(\d*) *- *(\d*)' % (excluded,), flags=re.I)
_rx_content_range = re.compile(r'([^%s]+) (?:(\d+)-(\d+)|[*])/(?:(\d+)|[*])' % (excluded,))



class Range(object):
"""
Represents the Range header.
"""

def __init__(self, start, end):
def __init__(self, start, end, units='bytes'):
assert end is None or end >= 0, "Bad range end: %r" % end
self.start = start
self.end = end # non-inclusive
self.units = units

def range_for_length(self, length):
"""
Expand Down Expand Up @@ -52,16 +61,14 @@ def content_range(self, length):
def __str__(self):
s,e = self.start, self.end
if e is None:
r = 'bytes=%s' % s
r = '%s=%s' % (self.units, s)
if s >= 0:
r += '-'
return r
return 'bytes=%s-%s' % (s, e-1)
return '%s=%s-%s' % (self.units, s, e-1)

def __repr__(self):
return '%s(%r, %r)' % (
self.__class__.__name__,
self.start, self.end)
return '<%s %s>' % (self.__class__.__name__, self)

def __iter__(self):
return iter((self.start, self.end))
Expand All @@ -74,7 +81,7 @@ def parse(cls, header):
m = _rx_range.match(header or '')
if not m:
return None
start, end = m.groups()
units, start, end = m.groups()
if not start:
return cls(-int(end), None)
start = int(start)
Expand All @@ -83,7 +90,7 @@ def parse(cls, header):
end = int(end) + 1 # return val is non-inclusive
if start >= end:
return None
return cls(start, end)
return cls(start, end, units)


class ContentRange(object):
Expand All @@ -95,13 +102,14 @@ class ContentRange(object):
can be ``*`` (represented as None in the attributes).
"""

def __init__(self, start, stop, length):
def __init__(self, start, stop, length, units='bytes'):
if not _is_content_range_valid(start, stop, length):
raise ValueError(
"Bad start:stop/length: %r-%r/%r" % (start, stop, length))
self.start = start
self.stop = stop # this is python-style range end (non-inclusive)
self.length = length
self.units = units

def __repr__(self):
return '<%s %s>' % (self.__class__.__name__, self)
Expand All @@ -113,9 +121,9 @@ def __str__(self):
length = self.length
if self.start is None:
assert self.stop is None
return 'bytes */%s' % length
return '%s */%s' % (self.units, length)
stop = self.stop - 1 # from non-inclusive to HTTP-style
return 'bytes %s-%s/%s' % (self.start, stop, length)
return '%s %s-%s/%s' % (self.units, self.start, stop, length)

def __iter__(self):
"""
Expand All @@ -133,14 +141,14 @@ def parse(cls, value):
m = _rx_content_range.match(value or '')
if not m:
return None
s, e, l = m.groups()
units, s, e, l = m.groups()
if s:
s = int(s)
e = int(e) + 1
l = l and int(l)
if not _is_content_range_valid(s, e, l, response=True):
return None
return cls(s, e, l)
return cls(s, e, l, units)


def _is_content_range_valid(start, stop, length, response=False):
Expand Down

0 comments on commit bcb4e5a

Please sign in to comment.