def test_settings_legacy(settings): settings.DJANGO_UNICORN = {} settings.DJANGO_UNICORN["CACHE_ALIAS"] = "unicorn_cache" expected = "unicorn_cache" actual = get_cache_alias() assert expected == actual
def _handle_component_request( request: HttpRequest, component_request: ComponentRequest ) -> Dict: """ Process a `ComponentRequest` by adding it to the cache and then either: - processing all of the component requests in the cache and returning the resulting value if it is the first component request for that particular component name + component id combination - return a `dict` saying that the request has been queued Args: param request: HttpRequest for the function-based view. param: component_request: Component request to process. Returns: `dict` with the following structure: { "id": component_id, "dom": html, // re-rendered version of the component after actions in the payload are completed "data": {}, // updated data after actions in the payload are completed "errors": {}, // form validation errors "return": {}, // optional return value from an executed action "parent": {}, // optional representation of the parent component } """ # If serial isn't enabled or the wrong cache, just process the request like normal if not get_serial_enabled(): return _process_component_request(request, component_request) cache = caches[get_cache_alias()] # Add the current request `ComponentRequest` to the cache queue_cache_key = f"unicorn:queue:{component_request.id}" component_requests = cache.get(queue_cache_key) or [] # Remove `request` from `ComponentRequest` before caching because it is not pickleable component_request.request = None component_requests.append(component_request) cache.set( queue_cache_key, component_requests, timeout=get_serial_timeout(), ) if len(component_requests) > 1: original_epoch = component_requests[0].epoch return { "queued": True, "epoch": component_request.epoch, "original_epoch": original_epoch, } return _handle_queued_component_requests( request, component_request.name, queue_cache_key )
def test_settings_cache_alias(settings): settings.UNICORN["CACHE_ALIAS"] = "unicorn_cache" expected = "unicorn_cache" actual = get_cache_alias() assert expected == actual
def create( component_id: str, component_name: str, component_key: str = "", parent: "UnicornView" = None, request: HttpRequest = None, use_cache=True, kwargs: Dict[str, Any] = {}, ) -> "UnicornView": """ Find and instantiate a component class based on `component_name`. Args: param component_id: Id of the component. Required. param component_name: Name of the component. Used to locate the correct `UnicornView` component class and template if necessary. Required. param component_key: Key of the component to allow multiple components of the same name to be differentiated. Optional. param parent: The parent component of the current component. param kwargs: Keyword arguments for the component passed in from the template. Defaults to `{}`. Returns: Instantiated `UnicornView` component. Raises `ComponentLoadError` if the component could not be loaded. """ assert component_id, "Component id is required" assert component_name, "Component name is required" @timed def _get_component_class(module_name: str, class_name: str) -> Type[UnicornView]: """ Imports a component based on module and class name. """ module = importlib.import_module(module_name) component_class = getattr(module, class_name) return component_class cache = caches[get_cache_alias()] component_cache_key = f"unicorn:component:{component_id}" cached_component = cache.get(component_cache_key) if cached_component: # Get the newest version of the parent from cache if it is available # This needs to happen for Django cache because instances is pickled, so # a change in the view won't be reflected automatically (like with the module # cache) so it needs to be retrieved manually. if cached_component.parent: cached_parent_component = cache.get( cached_component.parent.component_cache_key) if cached_parent_component: cached_component.parent = cached_parent_component cached_component.parent.setup(request) else: cached_component = constructed_views_cache.get(component_id) if use_cache and cached_component: # Note that `hydrate()` and `complete` don't need to be called here # because this path only happens for re-rendering from the view cached_component.setup(request) cached_component._validate_called = False cached_component.calls = [] logger.debug( f"Retrieve {component_id} from constructed views cache") return cached_component if component_id in views_cache: (component_class, parent, kwargs) = views_cache[component_id] component = construct_component( component_class=component_class, component_id=component_id, component_name=component_name, component_key=component_key, parent=parent, request=request, **kwargs, ) logger.debug(f"Retrieve {component_id} from views cache") return component locations = [] if component_name in location_cache: locations.append(location_cache[component_name]) else: locations = get_locations(component_name) # Store the last exception that got raised while looking for a component in case it is useful context last_exception: Union[Optional[ModuleNotFoundError], Optional[AttributeError]] = None for (class_name, module_name) in locations: try: component_class = _get_component_class(module_name, class_name) component = construct_component( component_class=component_class, component_id=component_id, component_name=component_name, component_key=component_key, parent=parent, request=request, **kwargs, ) # Put the location for the component name in a module cache location_cache[component_name] = (class_name, module_name) # Put the component's class in a module cache views_cache[component_id] = (component_class, parent, kwargs) # Put the instantiated component into a module cache and the Django cache cacheable_component = None try: cacheable_component = get_cacheable_component(component) except UnicornCacheError as e: logger.warning(e) if cacheable_component: if COMPONENTS_MODULE_CACHE_ENABLED: constructed_views_cache[ component_id] = cacheable_component cache.set(cacheable_component.component_cache_key, cacheable_component) return component except ModuleNotFoundError as e: last_exception = e except AttributeError as e: last_exception = e raise ComponentLoadError( f"'{component_name}' component could not be loaded: {last_exception}" ) from last_exception
def _process_component_request( request: HttpRequest, component_request: ComponentRequest ) -> Dict: """ Process a `ComponentRequest`: 1. construct a Component view 2. set all of the properties on the view from the data 3. execute the type - update the properties based on the payload for "syncInput" - call the method specified for "callMethod" 4. validate any fields specified in a Django form 5. construct a `dict` that will get returned in a `JsonResponse` later on Args: param request: HttpRequest for the function-based view. param: component_request: Component request to process. Returns: `dict` with the following structure: { "id": component_id, "dom": html, // re-rendered version of the component after actions in the payload are completed "data": {}, // updated data after actions in the payload are completed "errors": {}, // form validation errors "return": {}, // optional return value from an executed action "parent": {}, // optional representation of the parent component } """ component = UnicornView.create( component_id=component_request.id, component_name=component_request.name, request=request, ) # Get a deepcopy of the data passed in to determine what fields are updated later original_data = copy.deepcopy(component_request.data) # Set component properties based on request data for (property_name, property_value) in component_request.data.items(): set_property_from_data(component, property_name, property_value) component.hydrate() validate_all_fields = False is_reset_called = False is_refresh_called = False return_data = None partials = [] for action in component_request.action_queue: if action.partial: partials.append(action.partial) else: partials = action.partials if action.action_type == "syncInput": sync_input.handle(component_request, component, action.payload) elif action.action_type == "callMethod": ( component, _is_refresh_called, _is_reset_called, _validate_all_fields, return_data, ) = call_method.handle(component_request, component, action.payload) is_refresh_called = is_refresh_called | _is_refresh_called is_reset_called = is_reset_called | _is_reset_called validate_all_fields = validate_all_fields | _validate_all_fields else: raise UnicornViewError(f"Unknown action_type '{action.action_type}'") component.complete() # Re-load frontend context variables to deal with non-serializable properties component_request.data = orjson.loads(component.get_frontend_context_variables()) # Get set of attributes that should be marked as `safe` safe_fields = [] if hasattr(component, "Meta") and hasattr(component.Meta, "safe"): if isinstance(component.Meta.safe, Sequence): for field_name in component.Meta.safe: if field_name in component._attributes().keys(): safe_fields.append(field_name) # Mark safe attributes as such before rendering for field_name in safe_fields: value = getattr(component, field_name) if isinstance(value, str): setattr(component, field_name, mark_safe(value)) # Send back all available data for reset or refresh actions updated_data = component_request.data if not is_reset_called: if not is_refresh_called: updated_data = {} for key, value in original_data.items(): if value != component_request.data.get(key): updated_data[key] = component_request.data.get(key) if validate_all_fields: component.validate() else: component.validate(model_names=list(updated_data.keys())) # Pass the current request so that it can be used inside the component template rendered_component = component.render(request=request) component.rendered(rendered_component) cache = caches[get_cache_alias()] try: cache.set(component.component_cache_key, get_cacheable_component(component)) except UnicornCacheError as e: logger.warning(e) partial_doms = [] if partials and all(partials): soup = BeautifulSoup(rendered_component, features="html.parser") for partial in partials: partial_found = False only_id = False only_key = False target = partial.get("target") if not target: target = partial.get("key") if target: only_key = True if not target: target = partial.get("id") if target: only_id = True assert target, "Partial target is required" if not only_id: for element in soup.find_all(): if ( "unicorn:key" in element.attrs and element.attrs["unicorn:key"] == target ): partial_doms.append({"key": target, "dom": str(element)}) partial_found = True break if not partial_found and not only_key: for element in soup.find_all(): if "id" in element.attrs and element.attrs["id"] == target: partial_doms.append({"id": target, "dom": str(element)}) partial_found = True break res = { "id": component_request.id, "data": updated_data, "errors": component.errors, "calls": component.calls, "checksum": generate_checksum(orjson.dumps(component_request.data)), } if partial_doms: res.update({"partials": partial_doms}) else: hash = generate_checksum(rendered_component) if ( component_request.hash == hash and (not return_data or not return_data.value) and not component.calls ): raise RenderNotModified() # Make sure that partials with comments or blank lines before the root element only return the root element soup = BeautifulSoup(rendered_component, features="html.parser") rendered_component = str(get_root_element(soup)) res.update( { "dom": rendered_component, "hash": hash, } ) if return_data: res.update( { "return": return_data.get_data(), } ) if return_data.redirect: res.update( { "redirect": return_data.redirect, } ) if return_data.poll: res.update( { "poll": return_data.poll, } ) parent_component = component.parent if parent_component: # TODO: Should parent_component.hydrate() be called? parent_frontend_context_variables = loads( parent_component.get_frontend_context_variables() ) parent_checksum = generate_checksum(dumps(parent_frontend_context_variables)) parent = { "id": parent_component.component_id, "checksum": parent_checksum, } if not partial_doms: parent_dom = parent_component.render() component.parent_rendered(parent_dom) try: cache.set( parent_component.component_cache_key, get_cacheable_component(parent_component), ) except UnicornCacheError as e: logger.warning(e) parent.update( { "dom": parent_dom, "data": parent_frontend_context_variables, "errors": parent_component.errors, } ) res.update({"parent": parent}) return res
def _handle_queued_component_requests( request: HttpRequest, component_name: str, queue_cache_key ) -> Dict: """ Process the current component requests that are stored in cache. Also recursively checks for new requests that might have happened while executing the first request, merges them together and returns the correct appropriate data. Args: param request: HttpRequest for the view. param: component_name: Name of the component, e.g. "hello-world". param: queue_cache_key: Cache key created from component id which should be unique for any particular user's request lifecycle. Returns: JSON with the following structure: { "id": component_id, "dom": html, // re-rendered version of the component after actions in the payload are completed "data": {}, // updated data after actions in the payload are completed "errors": {}, // form validation errors "return": {}, // optional return value from an executed action "parent": {}, // optional representation of the parent component } """ cache = caches[get_cache_alias()] # Handle current request and any others in the cache by first sorting all of the current requests by ascending order component_requests = cache.get(queue_cache_key) if not component_requests or not isinstance(component_requests, list): raise UnicornViewError(f"No request found for {queue_cache_key}") component_requests = sorted(component_requests, key=lambda r: r.epoch) first_component_request = component_requests[0] try: # Can't store request on a `ComponentRequest` and cache it because `HttpRequest` isn't pickleable first_json_result = _process_component_request(request, first_component_request) except RenderNotModified: # Catching this and re-raising, but need the finally clause to clear the cache raise finally: # Re-check for requests after the first request is processed component_requests = cache.get(queue_cache_key) # Check that the request is in the cache before popping it off if component_requests: component_requests.pop(0) cache.set( queue_cache_key, component_requests, timeout=get_serial_timeout(), ) if component_requests: # Create one new `component_request` from all of the queued requests that can be processed merged_component_request = None for additional_component_request in copy.deepcopy(component_requests): if merged_component_request: # Add new component request action queue to the merged component request merged_component_request.action_queue.extend( additional_component_request.action_queue ) # Originally, the thought was to merge the `additional_component_request.data` into # the `merged_component_request.data`, but I can't figure out a way to do that in a sane # manner. This means that for rapidly fired events that mutate `data`, that new # `data` with be "thrown away". # Relevant test: test_call_method_multiple.py::test_message_call_method_multiple_with_updated_data else: merged_component_request = additional_component_request # Set new component request data from the first component request's resulting data for key, val in first_json_result.get("data", {}).items(): merged_component_request.data[key] = val component_requests.pop(0) cache.set( queue_cache_key, component_requests, timeout=get_serial_timeout(), ) merged_json_result = _handle_component_request( request, merged_component_request ) return merged_json_result return first_json_result