Repository: falconry/falcon
Pull request #1630 (2020-01-04 11:58:00)

Rewrites behaviour of Response.set_headers() method
# Summary of Changes

Changed behavior in `Response.set_headers()` the method, now we're checking for interface compliance, instead of check whether an object is an instance of ``dict``.

Also, new changes allow passing a custom class which will be setting headers for a response.
e.g
```python
class CustomHeaders:
    # ... custom flow etc.
    def items(self):
        return [('header', 'value')]  # or return {'header': 'value'}


class Resource:
    def on_get(self, req, resp):
        custom_headers = CustomHeaders()

        resp.set_headers(custom_headers)
```

# Related Issues

https://github.com/falconry/falcon/issues/1546

# Pull Request Checklist

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](https://github.com/falconry/falcon/blob/master/CONTRIBUTING.md) at least once; it will save you a few review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing to do.

- [x] Added **tests** for changed code.
- [ ] Prefixed code comments with GitHub nick and an appropriate prefix.
- [x] Coding style is consistent with the rest of the framework.
- [x] Updated **documentation** for changed code.
    - [ ] Added docstrings for any new classes, functions, or modules.
    - [x] Updated docstrings for any modifications to existing code.
    - [ ] Added references to new classes, functions, or modules to the relevant RST file under `docs/`.
    - [ ] Updated all relevant supporting documentation files under `docs/`.
    - [ ] A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/usage/restructuredtext/directives.html?highlight=versionadded#directive-versionadded).
- [ ] Changes (and possible deprecations) have [towncrier](https://pypi.org/project/towncrier/) news fragments under `docs/_newsfragments/`. (Run `towncrier --draft` to ensure it renders correctly.)

If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing!

*PR template inspired by the attrs project.*


codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=h1) Report
> Merging [#1630](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/79c33e91d1c21fed171bf6fc341b56f115bfc447?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1630/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1630   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          40      40           
  Lines        2692    2693    +1     
  Branches      397     397           
======================================
+ Hits         2692    2693    +1
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1630/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=footer). Last update [79c33e9...cbd26fa](https://codecov.io/gh/falconry/falcon/pull/1630?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/_newsfragments/1630-set-response.feature.rst b/docs/_newsfragments/1630-set-response.feature.rst
new file mode 100644
index 0000000..720b963
--- /dev/null
+++ b/docs/_newsfragments/1630-set-response.feature.rst
@@ -0,0 +1,2 @@
+The :meth:`~.Response.set_headers` method now accepts an instance of any dict-like
+object that implements an ``items()`` method.
\ No newline at end of file
diff --git a/falcon/response.py b/falcon/response.py
index d1fb099..21e477f 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -646,22 +646,24 @@ class Response:
             :meth:`~.append_header` or :meth:`~.set_cookie`.
 
         Args:
-            headers (dict or list): A dictionary of header names and values
-                to set, or a ``list`` of (*name*, *value*) tuples. Both
-                *name* and *value* must be of type ``str`` and
+            headers (Iterable[[str, str]]): An iterable of ``[name, value]`` two-member
+                iterables, or a dict-like object that implements an ``items()`` method.
+                Both *name* and *value* must be of type ``str`` and
                 contain only US-ASCII characters.
 
                 Note:
-                    Falcon can process a list of tuples slightly faster
+                    Falcon can process an iterable of tuples slightly faster
                     than a dict.
 
         Raises:
-            ValueError: `headers` was not a ``dict`` or ``list`` of ``tuple``.
-
+            ValueError: `headers` was not a ``dict`` or ``list`` of ``tuple``
+                         or ``Iterable[[str, str]]``.
         """
 
-        if isinstance(headers, dict):
-            headers = headers.items()
+        header_items = getattr(headers, 'items', None)
+
+        if callable(header_items):
+            headers = header_items()
 
         # NOTE(kgriffs): We can't use dict.update because we have to
         # normalize the header names.


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 9490b41..5d40d2d 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -255,6 +255,25 @@ class CORSHeaderResource:
         resp.body = "I'm a CORS test response"
 
 
+class CustomHeaders:
+    def items(self):
+        return [('test-header', 'test-value')]
+
+
+class CustomHeadersNotCallable:
+    def __init__(self):
+        self.items = {'test-header': 'test-value'}
+
+
+class CustomHeadersResource:
+    def on_get(self, req, resp):
+        headers = CustomHeaders()
+        resp.set_headers(headers)
+
+    def on_post(self, req, resp):
+        resp.set_headers(CustomHeadersNotCallable())
+
+
 class TestHeaders:
 
     def test_content_length(self, client):
@@ -790,6 +809,21 @@ class TestHeaders:
         assert result.headers['Access-Control-Allow-Headers'] == '*'
         assert result.headers['Access-Control-Max-Age'] == '86400'  # 24 hours in seconds
 
+    def test_set_headers_with_custom_class(self, client):
+        client.app.add_route('/', CustomHeadersResource())
+
+        result = client.simulate_get('/')
+
+        assert 'test-header' in result.headers
+        assert result.headers['test-header'] == 'test-value'
+
+    def test_headers_with_custom_class_not_callable(self, client):
+        client.app.add_route('/', CustomHeadersResource())
+
+        result = client.simulate_post('/')
+
+        assert 'test-header' not in result.headers
+
     def test_request_multiple_header(self, client):
         resource = HeaderHelpersResource()
         client.app.add_route('/', resource)