Repository: falconry/falcon (python)

Issue:
Change Request.if_none_match to return a list of ETag
The list should contain each ETag with quotes stripped. In the case of an asterisk, the result will be ``['*']``. 

TODO: How to handle the `W/` prefix? I believe it can simply be ignored, since the recipient is supposed to always use the weak comparison algorithm regardless of this prefix.

Pull request description:
refactor(falcon.request): Change etag headers to return a list of ETag
Closes #1211

`W/` prefix was simply ignored as proposed in the issue by @kgriffs.

Patch:
diff --git a/docs/api/util.rst b/docs/api/util.rst
index 9d8400e..f7e9771 100644
--- a/docs/api/util.rst
+++ b/docs/api/util.rst
@@ -19,3 +19,6 @@ Miscellaneous
 
 .. autoclass:: falcon.TimezoneGMT
     :members:
+
+.. autoclass:: falcon.ETag
+    :members:
diff --git a/falcon/request.py b/falcon/request.py
index 4e6ae76..669bdd7 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -354,10 +354,34 @@ class Request(object):
             accessed.)
         range_unit (str): Unit of the range parsed from the value of the
             Range header, or ``None`` if the header is missing
-        if_match (str): Value of the If-Match header, or ``None`` if the
-            header is missing.
-        if_none_match (str): Value of the If-None-Match header, or ``None``
-            if the header is missing.
+        if_match (list): Value of the If-Match header, as a parsed list of
+            :class:`falcon.ETag` objects or ``None`` if the header is missing.
+
+            This property is determined by the value of ``HTTP_IF_MATCH``
+            in the WSGI environment dict.
+
+            Note:
+                This property includes strong and weak entity-tags. Per
+                `RFC 7239`_, two entity-tags are equivalent if both are not
+                weak and their opaque-tags match character-by-character.
+
+            (See also: RFC 7239, Section 3.1)
+
+        if_none_match (list): Value of the If-None-Match header, as a parsed
+            list of :class:`falcon.ETag` objects or ``None`` if the header is
+            missing.
+
+            This property is determined by the value of ``HTTP_IF_NONE_MATCH``
+            in the WSGI environment dict.
+
+            Note:
+                This property includes strong and weak entity-tags. Per
+                `RFC 7239`_, two entity-tags are equivalent if their
+                opaque-tags match character-by-character, regardless of
+                either or both being tagged as weak.
+
+            (See also: RFC 7239, Section 3.2)
+
         if_modified_since (datetime): Value of the If-Modified-Since header,
             or ``None`` if the header is missing.
         if_unmodified_since (datetime): Value of the If-Unmodified-Since
@@ -504,8 +528,6 @@ class Request(object):
 
     expect = helpers.header_property('HTTP_EXPECT')
 
-    if_match = helpers.header_property('HTTP_IF_MATCH')
-    if_none_match = helpers.header_property('HTTP_IF_NONE_MATCH')
     if_range = helpers.header_property('HTTP_IF_RANGE')
 
     referer = helpers.header_property('HTTP_REFERER')
@@ -595,6 +617,14 @@ class Request(object):
     def date(self):
         return self.get_header_as_datetime('Date')
 
+    @property
+    def if_match(self):
+        return helpers.parse_etags(self.env.get('HTTP_IF_MATCH'))
+
+    @property
+    def if_none_match(self):
+        return helpers.parse_etags(self.env.get('HTTP_IF_NONE_MATCH'))
+
     @property
     def if_modified_since(self):
         return self.get_header_as_datetime('If-Modified-Since')
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
index 421b20e..db7ca02 100644
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -17,6 +17,7 @@
 import io
 import re
 
+from falcon.util import ETag
 from falcon.util.compat import http_cookies
 
 # https://tools.ietf.org/html/rfc6265#section-4.1.1
@@ -27,6 +28,8 @@ from falcon.util.compat import http_cookies
 #
 _COOKIE_NAME_RESERVED_CHARS = re.compile('[\x00-\x1F\x7F-\xFF()<>@,;:\\\\"/[\\]?={} \x09]')
 
+_ETAG_PATTERN = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)')
+
 
 def parse_cookie_header(header_value):
     """Parse a Cookie header value into a dict of named values.
@@ -110,6 +113,71 @@ def header_property(wsgi_name):
     return property(fget)
 
 
+def make_etag(value, is_weak=False):
+    """Creates and returns a ETag object.
+
+    Args:
+        value (str): Unquated entity tag value
+        is_weak (bool): The weakness indicator
+
+    Returns:
+        A ``str``-like Etag instance with weakness indicator.
+
+    """
+    etag = ETag(value)
+    etag.is_weak = is_weak
+    return etag
+
+
+def parse_etags(etag_str):
+    """
+    Parse a string of ETags given in the If-Match or If-None-Match header as
+    defined by RFC 7232.
+
+    Args:
+        etag_str (str): An ASCII header value to parse ETags from. ETag values
+            within may be prefixed by ``W/`` to indicate that the weak comparison
+            function should be used.
+
+    Returns:
+        A list of unquoted ETags or ``['*']`` if all ETags should be matched.
+
+    """
+    if etag_str is None:
+        return None
+
+    etags = []
+    etag_str = etag_str.strip()
+    if not etag_str:
+        return etags
+
+    if etag_str == '*':
+        etags.append(etag_str)
+        return etags
+
+    if ',' not in etag_str:
+        value = etag_str
+        is_weak = False
+        if value.startswith(('W/', 'w/')):
+            is_weak = True
+            value = value[2:]
+        if value[:1] == value[-1:] == '"':
+            value = value[1:-1]
+        etags.append(make_etag(value, is_weak))
+    else:
+        pos = 0
+        end = len(etag_str)
+        while pos < end:
+            match = _ETAG_PATTERN.match(etag_str, pos)
+            is_weak, quoted, raw = match.groups()
+            value = quoted or raw
+            if value:
+                etags.append(make_etag(value, bool(is_weak)))
+            pos = match.end()
+
+    return etags
+
+
 class BoundedStream(io.IOBase):
     """Wrap *wsgi.input* streams to make them more robust.
 
diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py
index 57c5e63..5f02a77 100644
--- a/falcon/util/__init__.py
+++ b/falcon/util/__init__.py
@@ -23,8 +23,6 @@ Conversely, the `uri` module must be imported explicitly::
 import json  # NOQA
 
 # Hoist misc. utils
-from falcon.util import structures
+from falcon.util.structures import *  # NOQA
 from falcon.util.misc import *  # NOQA
 from falcon.util.time import *  # NOQA
-
-CaseInsensitiveDict = structures.CaseInsensitiveDict
diff --git a/falcon/util/structures.py b/falcon/util/structures.py
index 91f5980..26d6e7b 100644
--- a/falcon/util/structures.py
+++ b/falcon/util/structures.py
@@ -105,3 +105,25 @@ class CaseInsensitiveDict(MutableMapping):  # pragma: no cover
 
     def __repr__(self):
         return '%s(%r)' % (self.__class__.__name__, dict(self.items()))
+
+
+class ETag(str):
+    """An entity-tag ``str``-like object with the weakness indicator.
+
+    Attributes:
+        is_weak (bool): The weakness indicator.
+
+    """
+    is_weak = False
+
+    def to_header(self):
+        """Convert the ETag into a HTTP header string.
+
+        Returns:
+            str: An opaque quoted string, possibly prefixed by a weakness
+            indicator ``W/``.
+
+        """
+        if self.is_weak:
+            return 'W/"%s"' % self
+        return '"%s"' % self


Test patch:
diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py
index cbecf4a..2e710ff 100644
--- a/tests/test_request_attrs.py
+++ b/tests/test_request_attrs.py
@@ -5,6 +5,7 @@ import pytest
 
 import falcon
 from falcon.request import Request, RequestOptions
+from falcon.request_helpers import make_etag
 import falcon.testing as testing
 import falcon.uri
 from falcon.util import compat
@@ -632,7 +633,7 @@ class TestRequestAttributes(object):
         assert getattr(req, attr) is None
 
     def test_attribute_headers(self):
-        hash = 'fa0d1a60ef6616bb28038515c8ea4cb2'
+        date = 'Wed, 21 Oct 2015 07:28:00 GMT'
         auth = 'HMAC_SHA1 c590afa9bb59191ffab30f223791e82d3fd3e3af'
         agent = 'testing/1.0.1'
         default_agent = 'curl/7.24.0 (x86_64-apple-darwin12.0)'
@@ -647,9 +648,7 @@ class TestRequestAttributes(object):
                                     'content_type')
         self._test_attribute_header('Expect', '100-continue', 'expect')
 
-        self._test_attribute_header('If-Match', hash, 'if_match')
-        self._test_attribute_header('If-None-Match', hash, 'if_none_match')
-        self._test_attribute_header('If-Range', hash, 'if_range')
+        self._test_attribute_header('If-Range', date, 'if_range')
 
         self._test_attribute_header('User-Agent', agent, 'user_agent',
                                     default=default_agent)
@@ -785,6 +784,41 @@ class TestRequestAttributes(object):
 
         assert req.app == ''
 
+    @pytest.mark.parametrize('etag,expected', [
+        ('', []),
+        (',', []),
+        ('*', ['*']),
+        (
+            'W/"67ab43"',
+            [make_etag('67ab43', is_weak=True)]
+        ),
+        (
+            'w/"67ab43"',
+            [make_etag('67ab43', is_weak=True)]
+        ),
+        (
+            '"67ab43"',
+            [make_etag('67ab43', is_weak=True)]
+        ),
+        (
+            '67ab43',
+            [make_etag('67ab43', is_weak=False)]
+        ),
+        (
+            'W/"67ab43", "54ed21", 42we85',
+            [make_etag('67ab43', is_weak=True),
+             make_etag('54ed21', is_weak=False),
+             make_etag('42we85', is_weak=False)]
+        ),
+    ])
+    def test_etag(self, etag, expected):
+        self._test_header_expected_value('If-Match', etag, 'if_match', expected)
+        self._test_header_expected_value('If-None-Match', etag, 'if_none_match', expected)
+
+    def test_etag_is_missing(self):
+        assert self.req.if_match is None
+        assert self.req.if_none_match is None
+
     # -------------------------------------------------------------------------
     # Helpers
     # -------------------------------------------------------------------------
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d7eb4a9..4890e35 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -9,7 +9,7 @@ import pytest
 import falcon
 from falcon import testing
 from falcon import util
-from falcon.util import compat, json, misc, uri
+from falcon.util import compat, json, misc, structures, uri
 
 
 def _arbitrary_uris(count, length):
@@ -332,6 +332,12 @@ class TestFalconUtils(object):
             falcon.get_http_status('-404.3')
         assert falcon.get_http_status(123, 'Go Away') == '123 Go Away'
 
+    def test_etag_to_header(self):
+        etag = structures.ETag('67ab43')
+        assert etag.to_header() == '"67ab43"'
+        etag.is_weak = True
+        assert etag.to_header() == 'W/"67ab43"'
+
 
 @pytest.mark.parametrize(
     'protocol,method',