Repository: sanic-org/sanic

Issue:
Design question: why is `Request` a subclass of `dict`?
Hello. I was just wondering: why is the `Request` class a subclass of `dict`? Would it be to enable developers do "store" information into a instance like `request["name"]`?

Thanks in advance!


Discussion:
Yes, here is the original PR for some context: https://github.com/channelcat/sanic/pull/163

Plus the original issue to this https://github.com/channelcat/sanic/issues/129
Thanks, @seemethere . The "impact" of using `dict` is almost neglectable - some rudimentary benchmarks on my old AMD workstation shows a 0.13 sec diff in 1mi iterations. Implementing basic `dict` interface (`__delitem__`, `__getitem__` and `__setitem__`) seems to show a diff of 0.09sec in 1mi iterations, that's the main reason I brought this up (again, I know, it's neglectable :wink:)
This is confusing for my exception serialization code which recognizes it wrongly as an empty dict.
If it is not really a dict, then it should not be sub-classed as one.
A solution based on `__getattr__` would have lesser impact and allow for nicer-looking extension code.

Resolving patch:
diff --git a/sanic/request.py b/sanic/request.py
--- a/sanic/request.py
+++ b/sanic/request.py
@@ -6,6 +6,7 @@
 
 from collections import defaultdict, namedtuple
 from http.cookies import SimpleCookie
+from types import SimpleNamespace
 from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
 
 from httptools import parse_url
@@ -71,7 +72,7 @@ def is_full(self):
         return self._queue.full()
 
 
-class Request(dict):
+class Request:
     """Properties of an HTTP request such as URL, headers, etc."""
 
     __slots__ = (
@@ -84,6 +85,7 @@ class Request(dict):
         "_socket",
         "app",
         "body",
+        "ctx",
         "endpoint",
         "headers",
         "method",
@@ -113,6 +115,7 @@ def __init__(self, url_bytes, headers, version, method, transport, app):
 
         # Init but do not inhale
         self.body_init()
+        self.ctx = SimpleNamespace()
         self.parsed_forwarded = None
         self.parsed_json = None
         self.parsed_form = None
@@ -129,10 +132,30 @@ def __repr__(self):
             self.__class__.__name__, self.method, self.path
         )
 
-    def __bool__(self):
-        if self.transport:
-            return True
-        return False
+    def get(self, key, default=None):
+        """.. deprecated:: 19.9
+           Custom context is now stored in `request.custom_context.yourkey`"""
+        return self.ctx.__dict__.get(key, default)
+
+    def __contains__(self, key):
+        """.. deprecated:: 19.9
+           Custom context is now stored in `request.custom_context.yourkey`"""
+        return key in self.ctx.__dict__
+
+    def __getitem__(self, key):
+        """.. deprecated:: 19.9
+           Custom context is now stored in `request.custom_context.yourkey`"""
+        return self.ctx.__dict__[key]
+
+    def __delitem__(self, key):
+        """.. deprecated:: 19.9
+           Custom context is now stored in `request.custom_context.yourkey`"""
+        del self.ctx.__dict__[key]
+
+    def __setitem__(self, key, value):
+        """.. deprecated:: 19.9
+           Custom context is now stored in `request.custom_context.yourkey`"""
+        setattr(self.ctx, key, value)
 
     def body_init(self):
         self.body = []


Test patch:
diff --git a/tests/test_request_data.py b/tests/test_request_data.py
--- a/tests/test_request_data.py
+++ b/tests/test_request_data.py
@@ -8,22 +8,72 @@
 except ImportError:
     from json import loads
 
+def test_custom_context(app):
+    @app.middleware("request")
+    def store(request):
+        request.ctx.user = "sanic"
+        request.ctx.session = None
+
+    @app.route("/")
+    def handler(request):
+        # Accessing non-existant key should fail with AttributeError
+        try:
+            invalid = request.ctx.missing
+        except AttributeError as e:
+            invalid = str(e)
+        return json({
+            "user": request.ctx.user,
+            "session": request.ctx.session,
+            "has_user": hasattr(request.ctx, "user"),
+            "has_session": hasattr(request.ctx, "session"),
+            "has_missing": hasattr(request.ctx, "missing"),
+            "invalid": invalid
+        })
+
+    request, response = app.test_client.get("/")
+    assert response.json == {
+        "user": "sanic",
+        "session": None,
+        "has_user": True,
+        "has_session": True,
+        "has_missing": False,
+        "invalid": "'types.SimpleNamespace' object has no attribute 'missing'",
+    }
+
 
-def test_storage(app):
+# Remove this once the deprecated API is abolished.
+def test_custom_context_old(app):
     @app.middleware("request")
     def store(request):
+        try:
+            request["foo"]
+        except KeyError:
+            pass
         request["user"] = "sanic"
-        request["sidekick"] = "tails"
+        sidekick = request.get("sidekick", "tails")  # Item missing -> default
+        request["sidekick"] = sidekick
+        request["bar"] = request["sidekick"]
         del request["sidekick"]
 
     @app.route("/")
     def handler(request):
         return json(
-            {"user": request.get("user"), "sidekick": request.get("sidekick")}
+            {
+                "user": request.get("user"),
+                "sidekick": request.get("sidekick"),
+                "has_bar": "bar" in request,
+                "has_sidekick": "sidekick" in request,
+            }
         )
 
     request, response = app.test_client.get("/")
 
+    assert response.json == {
+        "user": "sanic",
+        "sidekick": None,
+        "has_bar": True,
+        "has_sidekick": False,
+    }
     response_json = loads(response.text)
     assert response_json["user"] == "sanic"
     assert response_json.get("sidekick") is None
diff --git a/tests/test_requests.py b/tests/test_requests.py
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -1499,9 +1499,6 @@ def handler(request):
     request, response = app.test_client.get("/")
     assert bool(request)
 
-    request.transport = False
-    assert not bool(request)
-
 
 def test_request_parsing_form_failed(app, caplog):
     @app.route("/", methods=["POST"])