From f3d3f3940af5f6d2d0a835aa73c37956c8392c06 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Mon, 26 Jul 2021 21:00:56 +0200 Subject: [PATCH 1/5] Make it possible to use dot notation for setting context in reflex In an effort to improve the api for setting the context that is used in the final context in the reflex we introduce a dot notation for setting the context. Ie `reflex.context.my_context = 'value'`. For the old way of setting the context in instance variables you now also prevented to set an instance variable that is already used by the reflex. --- sockpuppet/consumer.py | 19 +++++++++++++ sockpuppet/reflex.py | 64 ++++++++++++++++++++++++++++++++++++++---- tests/test_reflex.py | 40 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/sockpuppet/consumer.py b/sockpuppet/consumer.py index 0799549..fe96955 100644 --- a/sockpuppet/consumer.py +++ b/sockpuppet/consumer.py @@ -5,6 +5,7 @@ import inspect from functools import wraps from os import walk, path +import types from urllib.parse import urlparse from urllib.parse import parse_qsl @@ -235,6 +236,14 @@ def render_page(self, reflex): reflex_context = {key: getattr(reflex, key) for key in instance_variables} reflex_context["stimulus_reflex"] = True + if not reflex.context._attr_data: + msg = ( + "Setting context through instance variables is deprecated, " + 'please use reflex.context.context_variable = "my_data"' + ) + logger.warning(msg) + reflex_context.update(reflex.context) + original_context_data = view.view_class.get_context_data reflex.get_context_data(**reflex_context) # monkey patch context method @@ -245,6 +254,16 @@ def render_page(self, reflex): ) response = view(reflex.request, *resolved.args, **resolved.kwargs) + + # When rendering the response the context has to be dict. + # Because django doesn't do the sane thing of forcing a dict we do it. + resolve_func = response.resolve_context + + def resolve_context(self, context): + return resolve_func(dict(context)) + + response.resolve_context = types.MethodType(resolve_context, response) + # we've got the response, the function needs to work as normal again view.view_class.get_context_data = original_context_data reflex.session.save() diff --git a/sockpuppet/reflex.py b/sockpuppet/reflex.py index a6f1da0..4bcf46e 100644 --- a/sockpuppet/reflex.py +++ b/sockpuppet/reflex.py @@ -1,3 +1,4 @@ +from collections import UserDict from django.urls import resolve from urllib.parse import urlparse @@ -5,26 +6,80 @@ PROTECTED_VARIABLES = [ "consumer", + "context", "element", + "params", "selectors", "session", "url", ] +class Context(UserDict): + """ + A dictionary that keeps track of whether it's been used as dictionary + or if values has been set with dot notation. We expect things to be set + in dot notation so a warning is issued until next major version (1.0) + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._attr_data = {} + + def __getitem__(self, key): + data = self.__dict__ + if ( + data["data"].get(key, KeyError) is KeyError + and data["_attr_data"].get(key, KeyError) is KeyError + ): + raise KeyError(key) + return self.data.get(key) or self._attr_data.get(key) + + def __setitem__(self, key, item): + if not self.__dict__.get("data"): + self.__dict__["data"] = {} + self.__dict__["data"][key] = item + + def __getattr__(self, key): + if not self.__dict__.get("data"): + self.__dict__["data"] = {} + if not self.__dict__.get("_attr_data"): + self.__dict__["_attr_data"] = {} + + if ( + self.__dict__["data"].get(key, KeyError) is KeyError + and self.__dict__["_attr_data"].get(key, KeyError) is KeyError + ): + raise AttributeError(key) + result = self.data.get(key) or self._attr_data.get(key) + return result + + def __setattr__(self, key, value): + if not self.__dict__.get("_attr_data"): + self.__dict__["_attr_data"] = {} + self.__dict__["_attr_data"][key] = value + + class Reflex: def __init__(self, consumer, url, element, selectors, params): self.consumer = consumer - self.url = url + self.context = Context() self.element = element + self.params = params self.selectors = selectors self.session = consumer.scope["session"] - self.params = params - self.context = {} + self.url = url + + self._init_run = True def __repr__(self): return f"" + def __setattr__(self, name, value): + if name in PROTECTED_VARIABLES and getattr(self, "_init_run", None): + raise ValueError("This instance variable is used by the reflex.") + super().__setattr__(name, value) + def get_context_data(self, *args, **kwargs): if self.context: self.context.update(**kwargs) @@ -44,8 +99,7 @@ def get_context_data(self, *args, **kwargs): view.object_list = view.get_queryset() context = view.get_context_data(**{"stimulus_reflex": True}) - - self.context = context + self.context.update(context) self.context.update(**kwargs) return self.context diff --git a/tests/test_reflex.py b/tests/test_reflex.py index acaa1c7..6912627 100644 --- a/tests/test_reflex.py +++ b/tests/test_reflex.py @@ -1,5 +1,6 @@ from django.test import TestCase from sockpuppet.test_utils import reflex_factory +from sockpuppet.reflex import Context class ReflexTests(TestCase): @@ -10,3 +11,42 @@ def test_reflex_can_access_context(self): self.assertIn('count', context) self.assertIn('otherCount', context) + + def test_context_api_works_correctly(self): + '''Test that context correctly stores information''' + context = Context() + context.hello = 'hello' + + self.assertEqual(context.hello, 'hello') + self.assertEqual(context['hello'], 'hello') + + self.assertEqual(context.data.get('hello'), None) + self.assertEqual(context._attr_data.get('hello'), 'hello') + + with self.assertRaises(AttributeError): + context.not_an_attribute + + with self.assertRaises(KeyError): + context['not_in_dictionary'] + + def test_access_attribute_when_stored_as_dict(self): + '''When value stored as dictionary it should be accessible as attribute''' + context = Context() + context['hello'] = 'world' + print(context.__dict__) + self.assertEqual(context['hello'], 'world') + self.assertEqual(context.hello, 'world') + + def test_update_context(self): + '''Update context with normal dictionary''' + + context = Context() + # update is broken. + context.update({'hello': 'world'}) + self.assertEqual(context.hello, 'world') + + def test_context_contains_none(self): + context = Context() + context.none = None + self.assertEqual(context.none, None) + self.assertEqual(context['none'], None) From c55b0153bde4958695a2d7febb631d7077c4a825 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 31 Jul 2021 12:24:28 +0200 Subject: [PATCH 2/5] Add more in-depth documentation --- sockpuppet/reflex.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/sockpuppet/reflex.py b/sockpuppet/reflex.py index 4bcf46e..6e3a4a3 100644 --- a/sockpuppet/reflex.py +++ b/sockpuppet/reflex.py @@ -17,10 +17,19 @@ class Context(UserDict): """ - A dictionary that keeps track of whether it's been used as dictionary - or if values has been set with dot notation. We expect things to be set - in dot notation so a warning is issued until next major version (1.0) + This class represents the context that will be rendered in a template + and then sent client-side through websockets. + + It works just like a dictionary with the extension that you can set and get + data through dot access. + + > context.my_data = 'hello' + > context.my_data # 'hello' """ + # NOTE for maintainer + # A dictionary that keeps track of whether it's been used as dictionary + # or if values has been set with dot notation. We expect things to be set + # in dot notation so a warning is issued until next major version (1.0) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -81,6 +90,15 @@ def __setattr__(self, name, value): super().__setattr__(name, value) def get_context_data(self, *args, **kwargs): + """ + Fetches the context from the view which the reflex belongs to. + Once you've made modifications you can update the reflex context. + + > context = self.get_context_data() + > context['a_key'] = 'some data' + > self.context.update(context) + """ + if self.context: self.context.update(**kwargs) return self.context @@ -112,6 +130,7 @@ def get_channel_id(self): @property def request(self): + """A synthetic request used to mimic the request-response cycle""" factory = RequestFactory() request = factory.get(self.url) request.session = self.consumer.scope["session"] @@ -120,5 +139,10 @@ def request(self): return request def reload(self): - """A default reflex to force a refresh""" + """ + A default reflex to force a refresh, when used in html it will + refresh the page + + data-action="click->MyReflexClass#reload" + """ pass From 25449ced3fa563ab0fde61828b65ad473493e292 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 31 Jul 2021 12:35:14 +0200 Subject: [PATCH 3/5] Only show warning if instance variables exceeds 0 --- sockpuppet/consumer.py | 7 +++++-- sockpuppet/reflex.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sockpuppet/consumer.py b/sockpuppet/consumer.py index fe96955..d64401e 100644 --- a/sockpuppet/consumer.py +++ b/sockpuppet/consumer.py @@ -231,12 +231,15 @@ def render_page(self, reflex): instance_variables = [ name for (name, member) in inspect.getmembers(reflex) - if not name.startswith("__") and name not in PROTECTED_VARIABLES + if not name.startswith("__") + and name not in PROTECTED_VARIABLES + and not callable(getattr(reflex, name)) ] + reflex_context = {key: getattr(reflex, key) for key in instance_variables} reflex_context["stimulus_reflex"] = True - if not reflex.context._attr_data: + if len(instance_variables) > 0: msg = ( "Setting context through instance variables is deprecated, " 'please use reflex.context.context_variable = "my_data"' diff --git a/sockpuppet/reflex.py b/sockpuppet/reflex.py index 6e3a4a3..f9d92ba 100644 --- a/sockpuppet/reflex.py +++ b/sockpuppet/reflex.py @@ -9,9 +9,11 @@ "context", "element", "params", + "request", "selectors", "session", "url", + "_init_run", ] @@ -26,6 +28,7 @@ class Context(UserDict): > context.my_data = 'hello' > context.my_data # 'hello' """ + # NOTE for maintainer # A dictionary that keeps track of whether it's been used as dictionary # or if values has been set with dot notation. We expect things to be set From 0da25074ea95ea39339a124f81e47baf89306bff Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 31 Jul 2021 15:33:50 +0200 Subject: [PATCH 4/5] Clean up in reflex --- requirements_dev.txt | 1 - requirements_test.txt | 3 +++ sockpuppet/reflex.py | 36 ++++++++++++------------------------ tests/test_reflex.py | 3 --- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index c536592..93fc817 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -6,4 +6,3 @@ invoke twine wheel zest.releaser - diff --git a/requirements_test.txt b/requirements_test.txt index 67ba509..5344cce 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,3 +6,6 @@ codecov>=2.0.0 gitpython invoke tox-venv + +pytest +pytest-django diff --git a/sockpuppet/reflex.py b/sockpuppet/reflex.py index f9d92ba..07d0998 100644 --- a/sockpuppet/reflex.py +++ b/sockpuppet/reflex.py @@ -27,6 +27,9 @@ class Context(UserDict): > context.my_data = 'hello' > context.my_data # 'hello' + + The following property will contain all data of the dictionary + > context.data """ # NOTE for maintainer @@ -34,42 +37,27 @@ class Context(UserDict): # or if values has been set with dot notation. We expect things to be set # in dot notation so a warning is issued until next major version (1.0) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._attr_data = {} - def __getitem__(self, key): data = self.__dict__ - if ( - data["data"].get(key, KeyError) is KeyError - and data["_attr_data"].get(key, KeyError) is KeyError - ): + if data["data"].get(key, KeyError) is KeyError: raise KeyError(key) - return self.data.get(key) or self._attr_data.get(key) - - def __setitem__(self, key, item): - if not self.__dict__.get("data"): - self.__dict__["data"] = {} - self.__dict__["data"][key] = item + return self.data.get(key) def __getattr__(self, key): if not self.__dict__.get("data"): self.__dict__["data"] = {} - if not self.__dict__.get("_attr_data"): - self.__dict__["_attr_data"] = {} - if ( - self.__dict__["data"].get(key, KeyError) is KeyError - and self.__dict__["_attr_data"].get(key, KeyError) is KeyError - ): + if self.__dict__["data"].get(key, KeyError) is KeyError: raise AttributeError(key) - result = self.data.get(key) or self._attr_data.get(key) + result = self.data.get(key) return result def __setattr__(self, key, value): - if not self.__dict__.get("_attr_data"): - self.__dict__["_attr_data"] = {} - self.__dict__["_attr_data"][key] = value + if not self.__dict__.get("data"): + self.__dict__["data"] = {} + if key == "data" and value == {}: + return + self.__dict__["data"][key] = value class Reflex: diff --git a/tests/test_reflex.py b/tests/test_reflex.py index 6912627..8a945da 100644 --- a/tests/test_reflex.py +++ b/tests/test_reflex.py @@ -20,9 +20,6 @@ def test_context_api_works_correctly(self): self.assertEqual(context.hello, 'hello') self.assertEqual(context['hello'], 'hello') - self.assertEqual(context.data.get('hello'), None) - self.assertEqual(context._attr_data.get('hello'), 'hello') - with self.assertRaises(AttributeError): context.not_an_attribute From 3a0e1e518059970040b315befb2ebb0b0ccad8ad Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 31 Jul 2021 17:11:02 +0200 Subject: [PATCH 5/5] Update documentation of how context is set --- docs/reflexes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reflexes.md b/docs/reflexes.md index 20a4ce3..b20d75e 100644 --- a/docs/reflexes.md +++ b/docs/reflexes.md @@ -83,16 +83,17 @@ class ExampleReflex(Reflex): def work(self): # All new instance variables in the reflex will be accessible # in the context during rendering. - self.instance_variable = 'hello world' + self.context.instance_variable = 'hello world' context = self.get_context_data() context['a_key'] = 'a pink elephant' + + self.context.update(context) # If "a_key" existed in the context before the reflex was triggered # the context variable will now be modified to "a pink elephant" # if it didn't exist, the context variable is then created with the # data "a pink elephant" 🐘 - ``` {% endtab %}