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 %} 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/consumer.py b/sockpuppet/consumer.py index 0799549..d64401e 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 @@ -230,11 +231,22 @@ 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 len(instance_variables) > 0: + 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 +257,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..07d0998 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,27 +6,90 @@ PROTECTED_VARIABLES = [ "consumer", + "context", "element", + "params", + "request", "selectors", "session", "url", + "_init_run", ] +class Context(UserDict): + """ + 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' + + The following property will contain all data of the dictionary + > context.data + """ + + # 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 __getitem__(self, key): + data = self.__dict__ + if data["data"].get(key, KeyError) is KeyError: + raise KeyError(key) + return self.data.get(key) + + def __getattr__(self, key): + if not self.__dict__.get("data"): + self.__dict__["data"] = {} + + if self.__dict__["data"].get(key, KeyError) is KeyError: + raise AttributeError(key) + result = self.data.get(key) + return result + + def __setattr__(self, key, value): + if not self.__dict__.get("data"): + self.__dict__["data"] = {} + if key == "data" and value == {}: + return + self.__dict__["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): + """ + 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 @@ -44,8 +108,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 @@ -58,6 +121,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"] @@ -66,5 +130,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 diff --git a/tests/test_reflex.py b/tests/test_reflex.py index acaa1c7..8a945da 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,39 @@ 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') + + 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)