From 80ea50449b97a6b96565571831651258d350227f Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 12:42:00 +0800 Subject: [PATCH 01/11] redirect: implement multiple paths and redirect decorator --- .pre-commit-config.yaml | 2 +- client_code/routing/__init__.py | 2 +- client_code/routing/_decorators.py | 83 ++++++++++--------- client_code/routing/_router.py | 129 +++++++++++++++++++---------- client_code/routing/_utils.py | 1 + 5 files changed, 131 insertions(+), 86 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c79a41f9..21d34b5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: # these are errors that will be ignored by flake8 # check out their meaning here # https://flake8.pycqa.org/en/latest/user/error-codes.html - - "--ignore=E203,E266,E501,W503,F403,F401,E402" + - "--ignore=E203,E266,E501,W503,F403,F401,E402,F811" - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.1.13 hooks: diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index 32fc7e3c..771bbd6f 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -11,7 +11,7 @@ from . import _navigation from . import _router as _r -from ._decorators import error_form, route, template +from ._decorators import error_form, redirect, route, template from ._logging import logger from ._router import NavigationExit, launch from ._utils import ( diff --git a/client_code/routing/_decorators.py b/client_code/routing/_decorators.py index 0e71526f..0fe6899a 100644 --- a/client_code/routing/_decorators.py +++ b/client_code/routing/_decorators.py @@ -8,22 +8,38 @@ from functools import wraps from . import _router -from ._utils import RouteInfo, TemplateInfo +from ._utils import RedirectInfo, RouteInfo, TemplateInfo __version__ = "2.0.1" -def template(path="", priority=0, condition=None): - if not isinstance(path, str): - raise TypeError("the first argument to template must be a str") +def _check_types_common(priority, condition): if not isinstance(priority, int): raise TypeError("the template priority must be an int") if condition is not None and not callable(condition): raise TypeError("the condition must be None or a callable") + +def _make_frozen_set(obj, attr): + if isinstance(obj, str): + return frozenset((obj,)) + rv = set() + for o in obj: + if not isinstance(o, str): + raise TypeError( + f"expected an iterable of strings or a string for {attr} argument" + ) + rv.add(o) + return frozenset(rv) + + +def template(path="", priority=0, condition=None): + _check_types_common(priority, condition) + path = _make_frozen_set(path, "path") + def template_wrapper(cls): info = TemplateInfo(cls, path, condition) - _router.add_template_info(cls, priority, info) + _router.add_top_level_info("template", cls, priority, info) cls_init = cls.__init__ @@ -52,47 +68,36 @@ def init_and_route(self, *args, **kws): return template_wrapper -class route: +def redirect(path, priority=0, condition=None): + _check_types_common(priority, condition) + path = _make_frozen_set(path, "path") + + def redirect_wrapper(fn): + info = RedirectInfo(fn, path, condition) + _router.add_top_level_info("redirect", redirect, priority, info) + return fn + + return redirect_wrapper + + +def route(url_pattern="", url_keys=[], title=None, full_width_row=False, template=None): """ the route decorator above any form you want to load in the content_panel @routing.route(url_pattern=str,url_keys=List[str], title=str) """ - - def __init__( - self, - url_pattern="", - url_keys=[], - title=None, - full_width_row=False, - template=None, - ): - self.url_pattern = url_pattern - self.url_keys = url_keys - self.title = title - self.fwr = full_width_row - self.url_parts = [] - self.templates = template - - def validate_args(self, cls): - if not isinstance(self.url_pattern, str): - raise TypeError( - f"url_pattern must be type str not {type(self.url_pattern)} in {cls.__name__}" - ) - if not (isinstance(self.url_keys, list) or isinstance(self.url_keys, tuple)): - raise TypeError( - f"keys should be a list or tuple not {type(self.url_keys)} in {cls.__name__}" - ) - if not (self.title is None or isinstance(self.title, str)): - raise TypeError( - f"title must be type str or None not {type(self.title)} in {cls.__name__}" - ) - - def __call__(self, cls): - self.validate_args(cls) - info = RouteInfo(form=cls, **self.__dict__) + if not isinstance(url_pattern, str): + raise TypeError(f"url_pattern must be type str not {type(url_pattern)}") + if not (title is None or isinstance(title, str)): + raise TypeError(f"title must be type str or None not {type(title)}") + url_keys = _make_frozen_set(url_keys, "url_keys") + + def route_wrapper(cls): + info = RouteInfo(cls, template, url_pattern, url_keys, title, full_width_row) _router.add_route_info(info) return cls + return route_wrapper + def error_form(cls): """optional decorator - this is the error form simply use the decorator above your error Form diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index 99aa4d13..d7ca3b8d 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -13,7 +13,7 @@ from ._alert import handle_alert_unload as _handle_alert_unload from ._logging import logger -from ._utils import get_url_components +from ._utils import RedirectInfo, TemplateInfo, get_url_components __version__ = "2.0.1" @@ -22,22 +22,40 @@ class NavigationExit(Exception): pass -class _StackDepthContext: +class navigation_context: + contexts = [] + def __init__(self): - self.stack_depth = 0 + self.stale = False + + def check_stale(self): + assert self is self.contexts[-1] + if self.stale: + raise NavigationExit def __enter__(self): - if self.stack_depth <= 5: - self.stack_depth += 1 - return - logger.debug( - "**WARNING**" - "\nurl_hash redirected too many times without a form load, getting out\ntry setting redirect=False" - ) - raise NavigationExit + num_contexts = len(self.contexts) + logger.debug(f"entering navigation {num_contexts}") + if not self.contexts: + self.contexts.append(self) + elif num_contexts <= 10: + for context in self.contexts: + context.stale = True + self.contexts.append(self) + else: + logger.debug( + "**WARNING**" + "\nurl_hash redirected too many times without a form load, getting out\ntry setting redirect=False" + ) + raise NavigationExit + return self def __exit__(self, exc_type, *args): - self.stack_depth -= 1 + self.contexts.pop() + num_contexts = len(self.contexts) + logger.debug(f"exiting navigation {num_contexts}") + if not num_contexts: + logger.debug("navigation complete\n") if exc_type is NavigationExit: return True @@ -65,14 +83,13 @@ class _Cache(dict): setdefault = _wrap_method(dict.setdefault) -stack_depth_context = _StackDepthContext() default_title = document.title _current_form = None _cache = _Cache() _routes = {} _templates = set() -_ordered_templates = {} +_ordered_info = {} _error_form = None _ready = False _queued = [] @@ -104,10 +121,12 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): f"\n\turl_pattern = {url_pattern!r}\n\turl_dict = {url_dict}" ) global _current_form - with stack_depth_context: + with navigation_context() as nav_context: handle_alert_unload() handle_form_unload() - template_info = load_template(url_pattern) + nav_context.check_stale() + template_info, init_path = load_template_or_redirect(url_pattern, nav_context) + nav_context.check_stale() url_args = { "url_hash": url_hash, "url_pattern": url_pattern, @@ -118,10 +137,11 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): form = _cache.get(url_hash) if form is None: form = get_form_to_add( - template_info, url_hash, url_pattern, url_dict, properties + template_info, init_path, url_hash, url_pattern, url_dict, properties ) else: logger.debug(f"{form.__class__.__name__!r} loading from cache") + nav_context.check_stale() _current_form = form update_form_attrs(form) add_form_to_container(form) @@ -147,34 +167,52 @@ def handle_form_unload(): raise NavigationExit -def load_template(url_hash): +def load_template_or_redirect(url_hash, nav_context: navigation_context): global _current_form form = get_open_form() current_cls = type(form) if form is not None and current_cls not in _templates: raise NavigationExit # not using templates - logger.debug("Checking routing templates") - for template_info in chain.from_iterable(_ordered_templates.values()): - cls, path, condition = template_info - if not url_hash.startswith(path): + logger.debug("checking routing templates and redirects") + for info in chain.from_iterable(_ordered_info.values()): + callable_, paths, condition = info + try: + path = next(path for path in paths if url_hash.startswith(path)) + except StopIteration: continue if condition is None: break - elif condition(): + elif not condition(): + continue + elif type(info) is TemplateInfo: break + redirect_hash = callable_() + if isinstance(redirect_hash, str): + from . import set_url_hash + + logger.debug(f"redirecting to url_hash {redirect_hash!r}") + + set_url_hash( + redirect_hash, + set_in_history=False, + redirect=True, + replace_current_url=True, + ) + nav_context.check_stale() + else: load_error_or_raise(f"No template for {url_hash!r}") - if current_cls is cls: - logger.debug(f"{cls.__name__!r} routing template unchanged") - return template_info + if current_cls is callable_: + logger.debug(f"{callable_.__name__!r} routing template unchanged") + return info, path else: logger.debug( - f"{current_cls.__name__!r} routing template changed to {cls.__name__!r}, exiting this navigation call" + f"{current_cls.__name__!r} routing template changed to {callable_.__name__!r}, exiting this navigation call" ) _current_form = None - f = cls() - logger.debug(f"form template loaded {cls.__name__!r}, re-navigating") + f = callable_() + logger.debug(f"form template loaded {callable_.__name__!r}, re-navigating") open_form(f) raise NavigationExit @@ -191,10 +229,12 @@ def clear_container(): get_open_form().content_panel.clear() -def get_form_to_add(template_info, url_hash, url_pattern, url_dict, properties): +def get_form_to_add( + template_info, init_path, url_hash, url_pattern, url_dict, properties +): global _current_form route_info, dynamic_vars = path_matcher( - template_info, url_hash, url_pattern, url_dict + template_info, init_path, url_hash, url_pattern, url_dict ) # check if path is cached with another template @@ -237,16 +277,16 @@ def load_error_or_raise(msg): raise LookupError(msg) -def path_matcher(template_info, url_hash, url_pattern, url_dict): +def path_matcher(template_info, init_path, url_hash, url_pattern, url_dict): given_parts = url_pattern.split("/") num_given_parts = len(given_parts) valid_routes = _routes.get(template_info.form.__name__, []) + _routes.get(None, []) for route_info in valid_routes: - if not route_info.url_pattern.startswith(template_info.path): + if not route_info.url_pattern.startswith(init_path): route_info = route_info._replace( - url_pattern=template_info.path + route_info.url_pattern + url_pattern=init_path + route_info.url_pattern ) if num_given_parts != len(route_info.url_parts): # url pattern CANNOT fit, skip deformatting @@ -329,18 +369,17 @@ def add_route_info(route_info): _routes.setdefault(template, []).append(route_info) -def add_template_info(cls, priority, template_info): - global _ordered_templates, _templates +def add_top_level_info(info_type, callable_, priority, info): + global _ordered_info, _templates logger.debug( - "template registered: (form={form.__name__!r}, path={path!r}, priority={priority}, condition={condition})".format( - priority=priority, **template_info._asdict() - ) + f"{info_type} registered: {repr(info).replace(type(info).__name__, '')}" ) - _templates.add(cls) - current = _ordered_templates - current.setdefault(priority, []).append(template_info) + if info_type == "template": + _templates.add(callable_) + tmp = _ordered_info + tmp.setdefault(priority, []).append(info) ordered = {} - for priority in sorted(current, reverse=True): + for priority in sorted(tmp, reverse=True): # rely on insertion order - ordered[priority] = current[priority] - _ordered_templates = ordered + ordered[priority] = tmp[priority] + _ordered_info = ordered diff --git a/client_code/routing/_utils.py b/client_code/routing/_utils.py index c3dfe43f..9aa639b0 100644 --- a/client_code/routing/_utils.py +++ b/client_code/routing/_utils.py @@ -104,6 +104,7 @@ def _get_url_hash(url_pattern, url_dict): ) TemplateInfo = namedtuple("template_info", ["form", "path", "condition"]) +RedirectInfo = namedtuple("redirect_info", ["redirect", "path", "condition"]) class RouteInfo(_RouteInfoBase): From b2b5420916b8e5fde2d3253131e0602ac3791c77 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 13:32:33 +0800 Subject: [PATCH 02/11] routing: make logger indent based on the routing navigation context --- client_code/routing/_logging.py | 9 ++++++++- client_code/routing/_router.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client_code/routing/_logging.py b/client_code/routing/_logging.py index 2badd749..24d1dd35 100644 --- a/client_code/routing/_logging.py +++ b/client_code/routing/_logging.py @@ -12,10 +12,17 @@ class Logger(_Logger): + def get_format_params(self, *, msg, **params): + from . import _router + + tabs = " " * len(_router.navigation_context.contexts) + msg = msg.replace("\n", "\n" + tabs) + return super().get_format_params(tabs=tabs, msg=msg, **params) + def __setattr__(self, attr: str, value) -> None: if attr == "debug": # backwards compatability return _Logger.__setattr__(self, "level", DEBUG if value else INFO) return _Logger.__setattr__(self, attr, value) -logger = Logger("#routing", format="{name}: {msg}", level=INFO) +logger = Logger("#routing", format="{tabs}{name}: {msg}", level=INFO) diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index d7ca3b8d..e4aeb7ae 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -35,7 +35,7 @@ def check_stale(self): def __enter__(self): num_contexts = len(self.contexts) - logger.debug(f"entering navigation {num_contexts}") + logger.debug(f"entering navigation level: {num_contexts}") if not self.contexts: self.contexts.append(self) elif num_contexts <= 10: @@ -53,7 +53,7 @@ def __enter__(self): def __exit__(self, exc_type, *args): self.contexts.pop() num_contexts = len(self.contexts) - logger.debug(f"exiting navigation {num_contexts}") + logger.debug(f"exiting navigation level:{num_contexts}") if not num_contexts: logger.debug("navigation complete\n") if exc_type is NavigationExit: From b88ec51367567bc1f82fd4424f8142174084f7c9 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 13:50:39 +0800 Subject: [PATCH 03/11] routing: improve some logging statments --- client_code/routing/__init__.py | 7 ++++--- client_code/routing/_logging.py | 2 +- client_code/routing/_router.py | 35 +++++++++++++++++---------------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index 771bbd6f..849d46a2 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -66,7 +66,7 @@ def on_session_expired(reload_hash=True, allow_cancel=True): def set_warning_before_app_unload(warning=True): """set a warning message before someone tries to navigate away from the app""" - logger.debug(f"Setting warning before app unload set to: {warning}") + logger.debug(f"setting warning before app unload set to: {warning!r}") def beforeunload(e): e.preventDefault() # cancel the event @@ -80,9 +80,10 @@ def remove_from_cache(url_hash=None, *, url_pattern=None, url_dict=None): gotcha: cannot be called from the init function of the the same form in cache because the form has not been added to the cache until it has loaded - try putthing it in the form show even instead """ - url_hash = _process_url_arguments( + url_args = _process_url_arguments( url_hash, url_pattern=url_pattern, url_dict=url_dict - )[0] + ) + url_hash = url_args[0] logger.debug(f"removing {url_hash!r} from cache") cached = _r._cache.pop(url_hash, None) if cached is None: diff --git a/client_code/routing/_logging.py b/client_code/routing/_logging.py index 24d1dd35..2b2e349e 100644 --- a/client_code/routing/_logging.py +++ b/client_code/routing/_logging.py @@ -16,7 +16,7 @@ def get_format_params(self, *, msg, **params): from . import _router tabs = " " * len(_router.navigation_context.contexts) - msg = msg.replace("\n", "\n" + tabs) + msg = msg.replace("\n", "\n" + " " * len(f"{tabs}{self.name}: ")) return super().get_format_params(tabs=tabs, msg=msg, **params) def __setattr__(self, attr: str, value) -> None: diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index e4aeb7ae..cde58cf5 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -53,7 +53,7 @@ def __enter__(self): def __exit__(self, exc_type, *args): self.contexts.pop() num_contexts = len(self.contexts) - logger.debug(f"exiting navigation level:{num_contexts}") + logger.debug(f"exiting navigation level: {num_contexts}") if not num_contexts: logger.debug("navigation complete\n") if exc_type is NavigationExit: @@ -117,8 +117,7 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): if url_hash is None: url_hash, url_pattern, url_dict = get_url_components() logger.debug( - f"navigation triggered\n\turl_hash = {url_hash!r}" - f"\n\turl_pattern = {url_pattern!r}\n\turl_dict = {url_dict}" + f"navigation triggered: url_hash={url_hash!r}, url_pattern={url_pattern!r}, url_dict={url_dict}" ) global _current_form with navigation_context() as nav_context: @@ -140,7 +139,7 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): template_info, init_path, url_hash, url_pattern, url_dict, properties ) else: - logger.debug(f"{form.__class__.__name__!r} loading from cache") + logger.debug(f"loading route: {form.__class__.__name__!r} from cache") nav_context.check_stale() _current_form = form update_form_attrs(form) @@ -162,7 +161,9 @@ def handle_form_unload(): with _navigation.PreventUnloading(): if before_unload(): - logger.debug(f"stop unload called from {_current_form.__class__.__name__}") + logger.debug( + f"stop unload called from route: {_current_form.__class__.__name__}" + ) _navigation.stopUnload() raise NavigationExit @@ -174,7 +175,7 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): if form is not None and current_cls not in _templates: raise NavigationExit # not using templates - logger.debug("checking routing templates and redirects") + logger.debug("checking templates and redirects") for info in chain.from_iterable(_ordered_info.values()): callable_, paths, condition = info try: @@ -191,7 +192,7 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): if isinstance(redirect_hash, str): from . import set_url_hash - logger.debug(f"redirecting to url_hash {redirect_hash!r}") + logger.debug(f"redirecting to url_hash: {redirect_hash!r}") set_url_hash( redirect_hash, @@ -202,17 +203,17 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): nav_context.check_stale() else: - load_error_or_raise(f"No template for {url_hash!r}") + load_error_or_raise(f"no template for url_hash={url_hash!r}") if current_cls is callable_: - logger.debug(f"{callable_.__name__!r} routing template unchanged") + logger.debug(f"unchanged template: {callable_.__name__!r}") return info, path else: logger.debug( - f"{current_cls.__name__!r} routing template changed to {callable_.__name__!r}, exiting this navigation call" + f"changing template: {current_cls.__name__!r} -> {callable_.__name__!r}, exiting this navigation call" ) _current_form = None f = callable_() - logger.debug(f"form template loaded {callable_.__name__!r}, re-navigating") + logger.debug(f"loaded template: {callable_.__name__!r}, re-navigating") open_form(f) raise NavigationExit @@ -243,12 +244,12 @@ def get_form_to_add( form = _cache.get((url_hash, template), None) if form is not None: logger.debug( - f"Loading {form.__class__.__name__!r} from cache - cached with {template!r}" + f"loading route: {form.__class__.__name__!r} from cache - cached with {template!r}" ) return form form = route_info.form.__new__(route_info.form, **properties) - logger.debug(f"adding {form.__class__.__name__!r} to cache") + logger.debug(f"adding route: {form.__class__.__name__!r} to cache") _current_form = _cache[url_hash] = form form._routing_props = { "title": route_info.title, @@ -262,7 +263,7 @@ def get_form_to_add( form.__init__(**properties) # this might be slow if it does a bunch of server calls if _current_form is not form: logger.debug( - f"Problem loading {form.__class__.__name__!r}. Another form was during the call to __init__. exiting this navigation" + f"problem loading route: {form.__class__.__name__!r}. Another form was during the call to __init__. exiting this navigation" ) # and if it was slow, and some navigation happened we should end now raise NavigationExit @@ -303,8 +304,8 @@ def path_matcher(template_info, init_path, url_hash, url_pattern, url_dict): return route_info, dynamic_vars logger.debug( - f"no route form with:\n\turl_pattern={url_pattern!r}\n\turl_keys={list(url_dict.keys())}" - f"\n\ttemplate={template_info.form.__name__!r}\n" + f"no route form with: url_pattern={url_pattern!r} url_keys={list(url_dict.keys())}" + f"template={template_info.form.__name__!r}\n" "If this is unexpected perhaps you haven't imported the form correctly" ) load_error_or_raise(f"{url_hash!r} does not exist") @@ -324,7 +325,7 @@ def update_form_attrs(form): document.title = title.format(**url_dict, **getattr(form, "dynamic_vars", {})) except Exception: raise ValueError( - "Error generating the page title. Please check the title argument in the decorator." + f"error generating the page title - check the title argument in {type(form).__name__!r} template decorator." ) From 374195e3fe9d2d30ef0c62be0bd1520182b06eb2 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 14:02:29 +0800 Subject: [PATCH 04/11] routing: improve when setting the url hash that startswith a hash --- client_code/routing/_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client_code/routing/_utils.py b/client_code/routing/_utils.py index 9aa639b0..acc7fc12 100644 --- a/client_code/routing/_utils.py +++ b/client_code/routing/_utils.py @@ -22,6 +22,9 @@ def get_url_components(url_hash=None): if url_hash is None: # url_hash = anvil.get_url_hash() #changed since anvil decodes the url_hash url_hash = location.hash[1:] # without the hash + elif isinstance(url_hash, str): + url_hash = url_hash if not url_hash.startswith("#") else url_hash[1:] + if isinstance(url_hash, dict): # this is the case when anvil converts the url hash to a dict automatically url_pattern = "" From 48be56980f5721f17e430b720f6d17423ca06b07 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 14:13:08 +0800 Subject: [PATCH 05/11] routing: prevent some cases of inifinite redirects --- client_code/routing/__init__.py | 4 +++- client_code/routing/_router.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index 849d46a2..0ce4c11b 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -157,11 +157,13 @@ def set_url_hash( if not load_from_cache: remove_from_cache(url_hash) + contexts = _r.navigation_context.contexts + context_hash = None if not contexts else contexts[-1].url_hash if ( url_hash == get_url_hash() and url_hash in _r._cache and _r._current_form is not None - ): + ) or context_hash == url_hash: return # should not continue if url_hash is identical to the addressbar hash! # but do continue if the url_hash is not in the cache i.e it was manually removed diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index cde58cf5..c135eb57 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -25,8 +25,9 @@ class NavigationExit(Exception): class navigation_context: contexts = [] - def __init__(self): + def __init__(self, url_hash): self.stale = False + self.url_hash = url_hash def check_stale(self): assert self is self.contexts[-1] @@ -120,7 +121,7 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): f"navigation triggered: url_hash={url_hash!r}, url_pattern={url_pattern!r}, url_dict={url_dict}" ) global _current_form - with navigation_context() as nav_context: + with navigation_context(url_hash) as nav_context: handle_alert_unload() handle_form_unload() nav_context.check_stale() @@ -190,6 +191,9 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): break redirect_hash = callable_() if isinstance(redirect_hash, str): + if redirect_hash == nav_context.url_hash: + continue + from . import set_url_hash logger.debug(f"redirecting to url_hash: {redirect_hash!r}") From 0001982d65d33eb5c8e1f5e10a808845745a9f85 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 17:40:03 +0800 Subject: [PATCH 06/11] routing: minor refactor --- client_code/routing/__init__.py | 33 +++++++----------- client_code/routing/_decorators.py | 31 +++++------------ client_code/routing/_logging.py | 8 ++--- client_code/routing/_router.py | 54 +++++++++++++++--------------- client_code/routing/_utils.py | 27 +++++++++------ 5 files changed, 69 insertions(+), 84 deletions(-) diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index 0ce4c11b..f186229f 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -33,9 +33,8 @@ def reload_page(hard=False): logger.debug("hard reload_page called") _w.location.reload() else: - logger.debug( - "reload_page called, clearing the cache for this page and reloading" - ) + msg = "reload_page called, clearing the cache for this page and reloading" + logger.debug(msg) url_hash, url_pattern, url_dict = get_url_components() remove_from_cache(url_hash) _r.navigate(url_hash, url_pattern, url_dict) @@ -80,16 +79,14 @@ def remove_from_cache(url_hash=None, *, url_pattern=None, url_dict=None): gotcha: cannot be called from the init function of the the same form in cache because the form has not been added to the cache until it has loaded - try putthing it in the form show even instead """ - url_args = _process_url_arguments( + url_hash = _process_url_arguments( url_hash, url_pattern=url_pattern, url_dict=url_dict - ) - url_hash = url_args[0] + )[0] logger.debug(f"removing {url_hash!r} from cache") cached = _r._cache.pop(url_hash, None) if cached is None: - logger.debug( - f"*warning* {url_hash!r} was not found in cache - maybe the form was yet to load" - ) + msg = f"*warning* {url_hash!r} was not found in cache - maybe the form was yet to load" + logger.debug(msg) def get_cache(): @@ -168,26 +165,22 @@ def set_url_hash( # but do continue if the url_hash is not in the cache i.e it was manually removed if set_in_history and not replace_current_url: - logger.debug( - f"setting url_hash to: '#{url_hash}', adding to top of history stack" - ) + msg = f"setting url_hash to: '#{url_hash}', adding to top of history stack" _navigation.pushState(url_hash) elif set_in_history and replace_current_url: - logger.debug( - f"setting url_hash to: '#{url_hash}', replacing current_url, setting in history" - ) + msg = f"setting url_hash to: '#{url_hash}', replacing current_url, setting in history" _navigation.replaceState(url_hash) elif not set_in_history and replace_current_url: - logger.debug( - f"setting url_hash to: '#{url_hash}', replacing current_url, NOT setting in history" - ) + msg = f"setting url_hash to: '#{url_hash}', replacing current_url, NOT setting in history" _navigation.replaceUrlNotState(url_hash) + logger.debug(msg) if redirect: - _r.navigate(url_hash, url_pattern, url_dict, **properties) - elif set_in_history and _r._current_form: + return _r.navigate(url_hash, url_pattern, url_dict, **properties) + if set_in_history and _r._current_form is not None: _r._cache[url_hash] = _r._current_form # no need to add to cache if not being set in history + logger.debug("navigation not triggered, redirect=False") def load_form(*args, **kws): diff --git a/client_code/routing/_decorators.py b/client_code/routing/_decorators.py index 0fe6899a..92605796 100644 --- a/client_code/routing/_decorators.py +++ b/client_code/routing/_decorators.py @@ -8,38 +8,25 @@ from functools import wraps from . import _router -from ._utils import RedirectInfo, RouteInfo, TemplateInfo +from ._utils import RedirectInfo, RouteInfo, TemplateInfo, _as_frozen_str_iterable __version__ = "2.0.1" -def _check_types_common(priority, condition): +def _check_types_common(path, priority, condition): if not isinstance(priority, int): raise TypeError("the template priority must be an int") if condition is not None and not callable(condition): raise TypeError("the condition must be None or a callable") - - -def _make_frozen_set(obj, attr): - if isinstance(obj, str): - return frozenset((obj,)) - rv = set() - for o in obj: - if not isinstance(o, str): - raise TypeError( - f"expected an iterable of strings or a string for {attr} argument" - ) - rv.add(o) - return frozenset(rv) + return _as_frozen_str_iterable(path, "path") def template(path="", priority=0, condition=None): - _check_types_common(priority, condition) - path = _make_frozen_set(path, "path") + path = _check_types_common(path, priority, condition) def template_wrapper(cls): info = TemplateInfo(cls, path, condition) - _router.add_top_level_info("template", cls, priority, info) + _router.add_info("template", cls, priority, info) cls_init = cls.__init__ @@ -69,12 +56,11 @@ def init_and_route(self, *args, **kws): def redirect(path, priority=0, condition=None): - _check_types_common(priority, condition) - path = _make_frozen_set(path, "path") + path = _check_types_common(path, priority, condition) def redirect_wrapper(fn): info = RedirectInfo(fn, path, condition) - _router.add_top_level_info("redirect", redirect, priority, info) + _router.add_info("redirect", redirect, priority, info) return fn return redirect_wrapper @@ -89,7 +75,8 @@ def route(url_pattern="", url_keys=[], title=None, full_width_row=False, templat raise TypeError(f"url_pattern must be type str not {type(url_pattern)}") if not (title is None or isinstance(title, str)): raise TypeError(f"title must be type str or None not {type(title)}") - url_keys = _make_frozen_set(url_keys, "url_keys") + url_keys = _as_frozen_str_iterable(url_keys, "url_keys") + template = _as_frozen_str_iterable(template, "template", allow_none=True) def route_wrapper(cls): info = RouteInfo(cls, template, url_pattern, url_keys, title, full_width_row) diff --git a/client_code/routing/_logging.py b/client_code/routing/_logging.py index 2b2e349e..dfc90769 100644 --- a/client_code/routing/_logging.py +++ b/client_code/routing/_logging.py @@ -15,9 +15,9 @@ class Logger(_Logger): def get_format_params(self, *, msg, **params): from . import _router - tabs = " " * len(_router.navigation_context.contexts) - msg = msg.replace("\n", "\n" + " " * len(f"{tabs}{self.name}: ")) - return super().get_format_params(tabs=tabs, msg=msg, **params) + indent = " " * len(_router.navigation_context.contexts) + msg = msg.replace("\n", "\n" + " " * len(f"{indent}{self.name}: ")) + return super().get_format_params(indent=indent, msg=msg, **params) def __setattr__(self, attr: str, value) -> None: if attr == "debug": # backwards compatability @@ -25,4 +25,4 @@ def __setattr__(self, attr: str, value) -> None: return _Logger.__setattr__(self, attr, value) -logger = Logger("#routing", format="{tabs}{name}: {msg}", level=INFO) +logger = Logger("#routing", format="{indent}{name}: {msg}", level=INFO) diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index c135eb57..79a17267 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -110,9 +110,8 @@ def launch(): def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): if not _ready: - logger.debug( - f"routing is not ready or the template has not finished loading: queuing the call {url_hash!r}" - ) + msg = f"routing is not ready or the template has not finished loading: queuing the call {url_hash!r}" + logger.debug(msg) _queued.append([(url_hash, url_pattern, url_dict), properties]) return if url_hash is None: @@ -234,6 +233,18 @@ def clear_container(): get_open_form().content_panel.clear() +def check_cached_templates(route_info, url_hash): + templates = route_info.template + if len(templates) <= 1: + return + for template in templates: + form = _cache.get((url_hash, template), None) + if form is not None: + msg = f"loading route: {form.__class__.__name__!r} from cache - cached with {template!r}" + logger.debug(msg) + return form + + def get_form_to_add( template_info, init_path, url_hash, url_pattern, url_dict, properties ): @@ -243,14 +254,9 @@ def get_form_to_add( ) # check if path is cached with another template - if len(route_info.templates) > 1: - for template in route_info.templates: - form = _cache.get((url_hash, template), None) - if form is not None: - logger.debug( - f"loading route: {form.__class__.__name__!r} from cache - cached with {template!r}" - ) - return form + form = check_cached_templates(route_info, url_hash) + if form is not None: + return form form = route_info.form.__new__(route_info.form, **properties) logger.debug(f"adding route: {form.__class__.__name__!r} to cache") @@ -266,9 +272,8 @@ def get_form_to_add( form.dynamic_vars = dynamic_vars form.__init__(**properties) # this might be slow if it does a bunch of server calls if _current_form is not form: - logger.debug( - f"problem loading route: {form.__class__.__name__!r}. Another form was during the call to __init__. exiting this navigation" - ) + msg = f"problem loading route: {form.__class__.__name__!r}. Another form was during the call to __init__. exiting this navigation" + logger.debug(msg) # and if it was slow, and some navigation happened we should end now raise NavigationExit return form @@ -328,9 +333,8 @@ def update_form_attrs(form): try: document.title = title.format(**url_dict, **getattr(form, "dynamic_vars", {})) except Exception: - raise ValueError( - f"error generating the page title - check the title argument in {type(form).__name__!r} template decorator." - ) + msg = f"error generating the page title - check the title argument in {type(form).__name__!r} template decorator." + raise ValueError(msg) def add_form_to_container(form): @@ -365,20 +369,16 @@ def load_error_form(): def add_route_info(route_info): - logger.debug( - " route registered: (form={form.__name__!r}, url_pattern={url_pattern!r}, url_keys={url_keys}, title={title!r})".format( - **route_info._asdict() - ) - ) - for template in route_info.templates: + msg = " route registered: (form={form.__name__!r}, url_pattern={url_pattern!r}, url_keys={url_keys}, title={title!r}, template={template!r})" + logger.debug(msg.format(**route_info._asdict())) + for template in route_info.template: _routes.setdefault(template, []).append(route_info) -def add_top_level_info(info_type, callable_, priority, info): +def add_info(info_type, callable_, priority, info): global _ordered_info, _templates - logger.debug( - f"{info_type} registered: {repr(info).replace(type(info).__name__, '')}" - ) + msg = f"{info_type} registered: {repr(info).replace(type(info).__name__, '')}" + logger.debug(msg) if info_type == "template": _templates.add(callable_) tmp = _ordered_info diff --git a/client_code/routing/_utils.py b/client_code/routing/_utils.py index acc7fc12..5903505f 100644 --- a/client_code/routing/_utils.py +++ b/client_code/routing/_utils.py @@ -101,9 +101,21 @@ def _get_url_hash(url_pattern, url_dict): return url_pattern + url_params +def _as_frozen_str_iterable(obj, attr, allow_none=False, factory=frozenset): + if isinstance(obj, str) or (allow_none and obj is None): + return factory([obj]) + rv = [] + for o in obj: + if not isinstance(o, str): + msg = f"expected an iterable of strings or a string for {attr} argument" + raise TypeError(msg) + rv.append(o) + return factory(rv) + + _RouteInfoBase = namedtuple( "route_info", - ["form", "templates", "url_pattern", "url_keys", "title", "fwr", "url_parts"], + ["form", "template", "url_pattern", "url_keys", "title", "fwr", "url_parts"], ) TemplateInfo = namedtuple("template_info", ["form", "path", "condition"]) @@ -117,19 +129,12 @@ def as_dynamic_var(part): return part[1:-1], True return part, False - def __new__(cls, form, templates, url_pattern, url_keys, title, fwr, url_parts=()): + def __new__(cls, form, template, url_pattern, url_keys, title, fwr, url_parts=()): if url_pattern.endswith("/"): url_pattern = url_pattern[:-1] - url_keys = frozenset(url_keys) - - if templates is None or type(templates) is str: - templates = (templates,) - elif type(templates) is not tuple: - templates = tuple(templates) - - url_parts = [cls.as_dynamic_var(part) for part in url_pattern.split("/")] + url_parts = tuple(cls.as_dynamic_var(part) for part in url_pattern.split("/")) return _RouteInfoBase.__new__( - cls, form, templates, url_pattern, url_keys, title, fwr, url_parts + cls, form, template, url_pattern, url_keys, title, fwr, url_parts ) From f9388b11c965f3541197471fb3343418cf6aa1d9 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 17:58:01 +0800 Subject: [PATCH 07/11] routing: fix contains for _Cache class --- client_code/routing/_router.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index 79a17267..e7bd14ac 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -79,6 +79,7 @@ class _Cache(dict): __getitem__ = _wrap_method(dict.__getitem__) __setitem__ = _wrap_method(dict.__setitem__) __delitem__ = _wrap_method(dict.__delitem__) + __contains__ = _wrap_method(dict.__contains__) get = _wrap_method(dict.get) pop = _wrap_method(dict.pop) setdefault = _wrap_method(dict.setdefault) From e75102fd44475ea1c66f66b235e290cbf957a726 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 18:19:16 +0800 Subject: [PATCH 08/11] routing: refactor navigation context checks --- client_code/routing/__init__.py | 4 +--- client_code/routing/_router.py | 36 +++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index f186229f..1279b86f 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -154,13 +154,11 @@ def set_url_hash( if not load_from_cache: remove_from_cache(url_hash) - contexts = _r.navigation_context.contexts - context_hash = None if not contexts else contexts[-1].url_hash if ( url_hash == get_url_hash() and url_hash in _r._cache and _r._current_form is not None - ) or context_hash == url_hash: + ) or _r.navigation_context.is_current_context(url_hash): return # should not continue if url_hash is identical to the addressbar hash! # but do continue if the url_hash is not in the cache i.e it was manually removed diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index e7bd14ac..3d68b2fa 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -26,14 +26,18 @@ class navigation_context: contexts = [] def __init__(self, url_hash): - self.stale = False + self.is_stale = False self.url_hash = url_hash - def check_stale(self): - assert self is self.contexts[-1] - if self.stale: + @classmethod + def check_stale(cls): + if cls.contexts and cls.contexts[-1].is_stale: raise NavigationExit + @classmethod + def is_current_context(cls, url_hash): + return cls.contexts and cls.contexts[-1].url_hash == url_hash + def __enter__(self): num_contexts = len(self.contexts) logger.debug(f"entering navigation level: {num_contexts}") @@ -41,7 +45,7 @@ def __enter__(self): self.contexts.append(self) elif num_contexts <= 10: for context in self.contexts: - context.stale = True + context.is_stale = True self.contexts.append(self) else: logger.debug( @@ -117,15 +121,18 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): return if url_hash is None: url_hash, url_pattern, url_dict = get_url_components() - logger.debug( - f"navigation triggered: url_hash={url_hash!r}, url_pattern={url_pattern!r}, url_dict={url_dict}" - ) + if navigation_context.is_current_context(url_hash): + return + + msg = f"navigation triggered: url_hash={url_hash!r}, url_pattern={url_pattern!r}, url_dict={url_dict}" + logger.debug(msg) + global _current_form with navigation_context(url_hash) as nav_context: handle_alert_unload() handle_form_unload() nav_context.check_stale() - template_info, init_path = load_template_or_redirect(url_pattern, nav_context) + template_info, init_path = load_template_or_redirect(url_pattern) nav_context.check_stale() url_args = { "url_hash": url_hash, @@ -169,7 +176,7 @@ def handle_form_unload(): raise NavigationExit -def load_template_or_redirect(url_hash, nav_context: navigation_context): +def load_template_or_redirect(url_hash): global _current_form form = get_open_form() current_cls = type(form) @@ -191,7 +198,7 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): break redirect_hash = callable_() if isinstance(redirect_hash, str): - if redirect_hash == nav_context.url_hash: + if navigation_context.is_current_context(redirect_hash): continue from . import set_url_hash @@ -204,7 +211,7 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): redirect=True, replace_current_url=True, ) - nav_context.check_stale() + navigation_context.check_stale() else: load_error_or_raise(f"no template for url_hash={url_hash!r}") @@ -212,9 +219,8 @@ def load_template_or_redirect(url_hash, nav_context: navigation_context): logger.debug(f"unchanged template: {callable_.__name__!r}") return info, path else: - logger.debug( - f"changing template: {current_cls.__name__!r} -> {callable_.__name__!r}, exiting this navigation call" - ) + msg = f"changing template: {current_cls.__name__!r} -> {callable_.__name__!r}" + logger.debug(msg) _current_form = None f = callable_() logger.debug(f"loaded template: {callable_.__name__!r}, re-navigating") From 466c5b08d0dbd251184a65942053bf655093c563 Mon Sep 17 00:00:00 2001 From: stu Date: Sun, 27 Mar 2022 23:30:28 +0800 Subject: [PATCH 09/11] routing: fix is current context --- client_code/routing/_router.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index 3d68b2fa..c8deef83 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -36,7 +36,11 @@ def check_stale(cls): @classmethod def is_current_context(cls, url_hash): - return cls.contexts and cls.contexts[-1].url_hash == url_hash + return ( + cls.contexts + and cls.contexts[-1].url_hash == url_hash + and _current_form is not None + ) def __enter__(self): num_contexts = len(self.contexts) From aaffa1aa5a9de0cec3a718fc85e22249bfddeba7 Mon Sep 17 00:00:00 2001 From: stu Date: Mon, 28 Mar 2022 12:43:18 +0800 Subject: [PATCH 10/11] maybe improve routing --- client_code/routing/_router.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index c8deef83..b2298952 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -31,32 +31,31 @@ def __init__(self, url_hash): @classmethod def check_stale(cls): - if cls.contexts and cls.contexts[-1].is_stale: + contexts = cls.contexts + if contexts and contexts[-1].is_stale: raise NavigationExit @classmethod def is_current_context(cls, url_hash): - return ( - cls.contexts - and cls.contexts[-1].url_hash == url_hash - and _current_form is not None - ) + current = cls.contexts and cls.contexts[-1] + return current and current.url_hash == url_hash and not current.is_stale + + @classmethod + def mark_all_stale(cls): + for context in cls.contexts: + context.is_stale = True def __enter__(self): num_contexts = len(self.contexts) logger.debug(f"entering navigation level: {num_contexts}") - if not self.contexts: - self.contexts.append(self) - elif num_contexts <= 10: - for context in self.contexts: - context.is_stale = True - self.contexts.append(self) - else: + self.mark_all_stale() + self.contexts.append(self) + if num_contexts >= 10: logger.debug( "**WARNING**" "\nurl_hash redirected too many times without a form load, getting out\ntry setting redirect=False" ) - raise NavigationExit + self.is_stale = True return self def __exit__(self, exc_type, *args): @@ -133,17 +132,18 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): global _current_form with navigation_context(url_hash) as nav_context: + nav_context.check_stale() handle_alert_unload() handle_form_unload() nav_context.check_stale() template_info, init_path = load_template_or_redirect(url_pattern) - nav_context.check_stale() url_args = { "url_hash": url_hash, "url_pattern": url_pattern, "url_dict": url_dict, } alert_on_navigation(**url_args) + nav_context.check_stale() clear_container() form = _cache.get(url_hash) if form is None: @@ -226,6 +226,7 @@ def load_template_or_redirect(url_hash): msg = f"changing template: {current_cls.__name__!r} -> {callable_.__name__!r}" logger.debug(msg) _current_form = None + navigation_context.mark_all_stale() f = callable_() logger.debug(f"loaded template: {callable_.__name__!r}, re-navigating") open_form(f) From 8804cc95fb90cb3055f6f762b3e8626e6f0b222b Mon Sep 17 00:00:00 2001 From: stu Date: Mon, 28 Mar 2022 15:00:53 +0800 Subject: [PATCH 11/11] routing: minor tweaks add docs and update change log --- CHANGELOG.md | 7 +- client_code/routing/__init__.py | 2 +- client_code/routing/_router.py | 17 ++-- docs/guides/modules/routing.rst | 168 +++++++++++++++++++++++++++++--- 4 files changed, 174 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318237cd..be2b044f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Unreleased ## Features -* routing - a template argument was added to the `@routing.route` decorator. +* routing - a template argument was added to the `@routing.route` decorator. This argument determines which templates a route can be added to. https://github.com/anvilistas/anvil-extras/issues/293 +* routing - a tempalate can take multiple paths `@routing.template(path=["admin", "user"])` + https://github.com/anvilistas/anvil-extras/pull/298 +* routing - `@routing.redirect()` decorator added + https://github.com/anvilistas/anvil-extras/pull/298 + # v2.0.1 16-Mar-2022 diff --git a/client_code/routing/__init__.py b/client_code/routing/__init__.py index 1279b86f..b0b7eef6 100644 --- a/client_code/routing/__init__.py +++ b/client_code/routing/__init__.py @@ -158,7 +158,7 @@ def set_url_hash( url_hash == get_url_hash() and url_hash in _r._cache and _r._current_form is not None - ) or _r.navigation_context.is_current_context(url_hash): + ) or _r.navigation_context.matches_current_context(url_hash): return # should not continue if url_hash is identical to the addressbar hash! # but do continue if the url_hash is not in the cache i.e it was manually removed diff --git a/client_code/routing/_router.py b/client_code/routing/_router.py index b2298952..9355f4a0 100644 --- a/client_code/routing/_router.py +++ b/client_code/routing/_router.py @@ -13,7 +13,7 @@ from ._alert import handle_alert_unload as _handle_alert_unload from ._logging import logger -from ._utils import RedirectInfo, TemplateInfo, get_url_components +from ._utils import TemplateInfo, get_url_components __version__ = "2.0.1" @@ -36,7 +36,7 @@ def check_stale(cls): raise NavigationExit @classmethod - def is_current_context(cls, url_hash): + def matches_current_context(cls, url_hash): current = cls.contexts and cls.contexts[-1] return current and current.url_hash == url_hash and not current.is_stale @@ -124,7 +124,7 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): return if url_hash is None: url_hash, url_pattern, url_dict = get_url_components() - if navigation_context.is_current_context(url_hash): + if navigation_context.matches_current_context(url_hash): return msg = f"navigation triggered: url_hash={url_hash!r}, url_pattern={url_pattern!r}, url_dict={url_dict}" @@ -132,6 +132,7 @@ def navigate(url_hash=None, url_pattern=None, url_dict=None, **properties): global _current_form with navigation_context(url_hash) as nav_context: + # it could be initially stale if there are 10+ active contexts nav_context.check_stale() handle_alert_unload() handle_form_unload() @@ -173,9 +174,8 @@ def handle_form_unload(): with _navigation.PreventUnloading(): if before_unload(): - logger.debug( - f"stop unload called from route: {_current_form.__class__.__name__}" - ) + msg = f"stop unload called from route: {_current_form.__class__.__name__}" + logger.debug(msg) _navigation.stopUnload() raise NavigationExit @@ -202,7 +202,9 @@ def load_template_or_redirect(url_hash): break redirect_hash = callable_() if isinstance(redirect_hash, str): - if navigation_context.is_current_context(redirect_hash): + if navigation_context.matches_current_context(redirect_hash): + # would cause an infinite loop + logger.debug("redirect returned current url_hash, ignoring") continue from . import set_url_hash @@ -226,6 +228,7 @@ def load_template_or_redirect(url_hash): msg = f"changing template: {current_cls.__name__!r} -> {callable_.__name__!r}" logger.debug(msg) _current_form = None + # mark context as stale so that this context is no longer considered the current context navigation_context.mark_all_stale() f = callable_() logger.debug(f"loaded template: {callable_.__name__!r}, re-navigating") diff --git a/docs/guides/modules/routing.rst b/docs/guides/modules/routing.rst index ba50b9ec..15242422 100644 --- a/docs/guides/modules/routing.rst +++ b/docs/guides/modules/routing.rst @@ -55,7 +55,7 @@ It only has a navigation bar, header, optional sidebar and a ``content_panel`` from .ErrorForm import ErrorForm @routing.template(path="", priority=0, condition=None) - class Main(MainTemplate): + class MainRouter(MainRouterTemplate): An Anvil app can have multiple template forms. @@ -64,6 +64,7 @@ check each registered template form in order of priority (highest values first). A template form will be loaded as the ``open_form`` only if, the current ``url_hash`` starts with the template's path argument **and** either the condition is ``None`` **or** the condition is a callable that returns ``True``. +The path argument can be a string or an iterable of strings. The above example would be the fallback template form. This is equivalent to: @@ -71,7 +72,7 @@ This is equivalent to: .. code:: python @routing.default_template - class Main(MainTemplate): + class MainRouter(MainRouterTemplate): If you have a different top-level template for the ``admin`` section of your app you might want a second template. @@ -81,7 +82,7 @@ If you have a different top-level template for the ``admin`` section of your app from .. import Globals @routing.template(path="admin", priority=1, condition=lambda: Globals.admin is not None) - class AdminForm(AdminTemplate): + class AdminRouterForm(AdminRouterTemplate): The above code takes advantage of an implied ``Globals`` module that has an ``admin`` attribute. If the ``url_hash`` starts with ``admin`` and the ``Globals.admin`` is not ``None`` then this template @@ -94,7 +95,7 @@ Another example might be a login template from .. import Globals @routing.template(path="", priority=2, condition=lambda: Globals.user is None) - class LoginForm(LoginTemplate): + class LoginRouterForm(LoginRouterTemplate): Note that ``TemplateForms`` are never cached (unlike ``RouteForms``). @@ -178,7 +179,7 @@ shows an error message: Startup Forms and Startup Modules --------------------------------- -If you are using a Startup Module or a Startup Form all the ``TemplateForm``s and ``RouteForm``s must +If you are using a Startup Module or a Startup Form all the ``TemplateForms`` and ``RouteForms`` must be imported otherwise they will not be registered by the ``routing`` module. If using a Startup module, it is recommended call ``routing.launch()`` after any initial app logic @@ -219,14 +220,14 @@ Instead .. code:: python # option 1 - set_url_hash('articles') # anvil's built in method + set_url_hash('articles') # anvil's built-in method # or an empty string to navigate to the home page set_url_hash('') # option 2 routing.set_url_hash('articles') - #routing.set_url_hash() method has some bonus features. + #routing.set_url_hash() method has some bonus features and is recommended over the anvil's built-in method With query string parameters: @@ -276,15 +277,51 @@ e.g. ``foo/article-{id}`` is not valid. -------------- +Redirects +--------- + +A redirect is similar to a template in that the arguments are the same. + +.. code:: python + + @routing.redirect(path="admin", priority=20, condition: Globals.user is None or not Globals.user["admin"]) + def redirect_no_admin(): + # not an admin or not logged in + return "login" + + # can also use routing.set_url_hash() to redirect + @routing.redirect(path="admin", priority=20, condition=lambda: Globals.user is None or not Globals.user["admin"]) + def redirect_no_admin(): + routing.set_url_hash("login", replace_current_url=True, set_in_history=False, redirect=True) + + + +When used as a decorator, the redirect function will be called if: + +- the current ``url_hash`` starts with the redirect ``path``, and +- the condition returns ``True`` or the condition is ``None`` + +The redirect function can return a ``url_hash``, which will then trigger a redirect. +Alternatively, a redirect can use ``routing.set_url_hash()`` to redirect. + +Redirects are checked at the same time as templates, in this way a redirect can intercept the current navigation before any templates are loaded. + + API --- Decorators ^^^^^^^^^^ -.. function:: routing.template(path='', priority=0, condition=None) +.. function:: routing.template(path='', priority=0, condition=None, redirect=None) Apply this decorator above the top-level Form - ``TemplateForm``. + + - ``path`` should be a string or iterable of strings. + - ``priority`` should be an integer. + - ``condition`` can be ``None``, or a function that returns ``True`` or ``False`` + The ``TemplateForm`` must have a ``content_panel``. + It is often could to refer to ``TemplateForm``s with the suffix ``Router`` e.g. ``MainRouter``, ``AdminRotuer``. There are two callbacks available to a ``TemplateForm``. .. method:: on_navitagion(self, **nav_args) @@ -339,6 +376,16 @@ Decorators If the ``before_unload`` method is added it will be called whenever the form currently in the ``content_panel`` is about to be removed. If any truthy value is returned then unloading will be prevented. See `Form Unloading <#form-unloading>`__. +.. function:: routing.redirect(path, priority=0, condition=None) + + The redirect decorator can decorate a function that will intercept the current navigtation, depending on its ``path``, ``priority`` and ``condition`` arguments. + + - ``path`` can be a string or iterable of strings. + - ``priority`` should be an integer - the higher the value the higher the priority. + - ``conditon`` should be ``None`` or a callable that returns a ``True`` or ``False``. + + A redirect function can return a ``url_hash`` - which will trigger a redirect, or it can call ``routing.set_url_hash()``. + .. attribute:: routing.error_form The ``routing.error_form`` decorator is optional and can be added above a form @@ -902,20 +949,119 @@ You can avoid this by raising a ``routing.NavigationExit()`` exception in the `` routing.set_url_hash("") -Alternatively, you could load the login form as a ``route`` form. +You may choose to use redirect functions to intercept the navigation. + +.. code:: python + + @routing.redirect("", priority=10, condition=lambda: Globals.user is None) + def redirect(): + return "login" + + @routing.redirect("login", priority=10, condition=lambda: Globals.user is not None) + def redirect(): + # we're logged in - don't go to the login form + return "" + + @routing.default_template + class DashboardRouter(DashboardRouterTemplate): + ... + + @routing.template("login", priority=1) + class LoginRouter(LoginRouterTemplate): + def on_navigation(self, url_hash, **url_args): + raise routing.NavigationExit + # prevent routing from changing the content panel + + def login_button_click(self, **event_args): + Globals.user = anvil.users.login_with_form() + routing.set_url_hash("", replace_current_url=True) + # let routing decide which template + + +Advanced - redirect back to the url hash that was being accessed + + +.. code:: python + + @routing.redirect("", priority=10, condition=lambda: Globals.user is None) + def redirect(): + current_hash = routing.get_url_hash() + routing.set_url_hash("login", current_hash=current_hash, replace_current_url=True, set_in_history=False) + # the extra property current_hash passed to the form as a keyword argument + + @routing.redirect("login", priority=10, condition=lambda: Globals.user is not None) + def redirect(): + # we're logged in - don't go to the login form + return "" + + @routing.default_template + class DashboardRouter(DashboardRouterTemplate): + ... + + @routing.template("login", priority=1) + class LoginRouter(LoginRouterTemplate): + def __init__(self, current_hash="", **properties): + self.current = current_hash + + def on_navigation(self, url_hash, **url_args): + self.current = url_hash + routing.set_url_hash("login", replace_current_url=True, set_in_history=False, redirect=False) + raise routing.NavigationExit + # prevent routing from changing the content panel + + def login_button_click(self, **event_args): + Globals.user = anvil.users.login_with_form() + routing.set_url_hash(self.current, replace_current_url=True) + # let routing decide which template to load + + +More advanced - to access the current ``url_hash`` that is stored in the browser's history you can use +``window.history.state.get.url``. + +.. code:: python + + @routing.redirect("", priority=10, condition=lambda: Globals.user is None) + def redirect(): + return "login" + + @routing.redirect("login", priority=10, condition=lambda: Globals.user is not None) + def redirect(): + return "" + + @routing.default_template + class DashboardRouter(DashboardRouterTemplate): + ... + + @routing.template("login", priority=1) + class LoginRouter(LoginRouterTemplate): + def on_navigation(self, **url_args): + routing.set_url_hash("login", replace_current_url=True, set_in_history=False, redirect=False) + raise routing.NavigationExit + # prevent routing from changing the content panel + + def login_button_click(self, **event_args): + Globals.user = anvil.users.login_with_form() + from anvil.js.window import history + routing.set_url_hash(history.state.url, replace_current_url=True) + + + + +Alternatively, you could load the login form as a ``route`` form rather than a template. .. code:: python @routing.default_template - class MainForm(Mainemplate): + class MainRouter(MainRouterTemplate): def __init__(self, **properties): if Globals.users is None: routing.set_url_hash("login") # this logic could also be in a Startup Module def on_navigation(self, url_hash, **url_args): if Globals.user is None and url_hash != "login": - raise routing.NavigationExit() # prevent routing from changing the content panel + raise routing.NavigationExit() + # prevent routing from changing the login route form inside the content panel @routing.route('login')