import functools import re from importlib import import_module from django.core.exceptions import ViewDoesNotExist from django.utils.module_loading import module_has_submodule from django.utils.regex_helper import _lazy_re_compile from django.utils.translation import gettext as _ @functools.cache def get_callable(lookup_view): """ Return a callable corresponding to lookup_view. * If lookup_view is already a callable, return it. * If lookup_view is a string import path that can be resolved to a callable, import that callable and return it, otherwise raise an exception (ImportError or ViewDoesNotExist). """ if callable(lookup_view): return lookup_view if not isinstance(lookup_view, str): raise ViewDoesNotExist( "'%s' is not a callable or a dot-notation path" % lookup_view ) mod_name, func_name = get_mod_func(lookup_view) if not func_name: # No '.' in lookup_view raise ImportError( "Could not import '%s'. The path must be fully qualified." % lookup_view ) try: mod = import_module(mod_name) except ImportError: parentmod, submod = get_mod_func(mod_name) if submod and not module_has_submodule(import_module(parentmod), submod): raise ViewDoesNotExist( "Could not import '%s'. Parent module %s does not exist." % (lookup_view, mod_name) ) else: raise else: try: view_func = getattr(mod, func_name) except AttributeError: raise ViewDoesNotExist( "Could not import '%s'. View does not exist in module %s." % (lookup_view, mod_name) ) else: if not callable(view_func): raise ViewDoesNotExist( "Could not import '%s.%s'. View is not callable." % (mod_name, func_name) ) return view_func def get_mod_func(callback): # Convert 'django.views.news.stories.story_detail' to # ['django.views.news.stories', 'story_detail'] try: dot = callback.rindex(".") except ValueError: return callback, "" return callback[:dot], callback[dot + 1 :] # Match the beginning of a named, unnamed, or non-capturing groups. _NAMED_GROUP_MATCHER = _lazy_re_compile(r"\(\?P(<\w+>)") _UNNAMED_GROUP_MATCHER = _lazy_re_compile(r"\(") _NON_CAPTURING_GROUP_MATCHER = _lazy_re_compile(r"\(\?\:") _LITERAL_ESCAPE_RE = _lazy_re_compile(r"\\([./()_-])") def replace_metacharacters(pattern): """Remove unescaped metacharacters from the pattern.""" return re.sub( r"((?:^|(?(x|y))/b' or '^b/((x|y)\w+)$'. unmatched_open_brackets, prev_char = 1, None for idx, val in enumerate(pattern[end:]): # Check for unescaped `(` and `)`. They mark the start and end of a # nested group. if val == "(" and prev_char != "\\": unmatched_open_brackets += 1 elif val == ")" and prev_char != "\\": unmatched_open_brackets -= 1 prev_char = val # If brackets are balanced, the end of the string for the current named # capture group pattern has been reached. if unmatched_open_brackets == 0: return start, end + idx + 1 def _find_groups(pattern, group_matcher): prev_end = None for match in group_matcher.finditer(pattern): if indices := _get_group_start_end(match.start(0), match.end(0), pattern): start, end = indices if prev_end and start > prev_end or not prev_end: yield start, end, match prev_end = end def replace_named_groups(pattern): r""" Find named groups in `pattern` and replace them with the group name. E.g., 1. ^(?P\w+)/b/(\w+)$ ==> ^/b/(\w+)$ 2. ^(?P\w+)/b/(?P\w+)/$ ==> ^/b//$ 3. ^(?P\w+)/b/(\w+) ==> ^/b/(\w+) 4. ^(?P\w+)/b/(?P\w+) ==> ^/b/ """ group_pattern_and_name = [ (pattern[start:end], match[1]) for start, end, match in _find_groups(pattern, _NAMED_GROUP_MATCHER) ] for group_pattern, group_name in group_pattern_and_name: pattern = pattern.replace(group_pattern, group_name) return pattern def replace_unnamed_groups(pattern): r""" Find unnamed groups in `pattern` and replace them with ''. E.g., 1. ^(?P\w+)/b/(\w+)$ ==> ^(?P\w+)/b/$ 2. ^(?P\w+)/b/((x|y)\w+)$ ==> ^(?P\w+)/b/$ 3. ^(?P\w+)/b/(\w+) ==> ^(?P\w+)/b/ 4. ^(?P\w+)/b/((x|y)\w+) ==> ^(?P\w+)/b/ """ final_pattern, prev_end = "", None for start, end, _ignored in _find_groups(pattern, _UNNAMED_GROUP_MATCHER): if prev_end: final_pattern += pattern[prev_end:start] final_pattern += pattern[:start] + "" prev_end = end return final_pattern + pattern[prev_end:] def remove_non_capturing_groups(pattern): r""" Find non-capturing groups in the given `pattern` and remove them, e.g. 1. (?P\w+)/b/(?:\w+)c(?:\w+) => (?P\\w+)/b/c 2. ^(?:\w+(?:\w+))a => ^a 3. ^a(?:\w+)/b(?:\w+) => ^a/b """ group_start_end_indices = _find_groups(pattern, _NON_CAPTURING_GROUP_MATCHER) final_pattern, prev_end = "", None for start, end, _ignored in group_start_end_indices: final_pattern += pattern[prev_end:start] prev_end = end return final_pattern + pattern[prev_end:] def unescape_literals(pattern): return _LITERAL_ESCAPE_RE.sub(r"\1", pattern) def extract_views_from_urlpatterns(urlpatterns, base="", namespace=None): """ Return a list of views from a list of urlpatterns. Each object in the returned list is a 4-tuple: (view_func, regex, namespace, name) """ views = [] for p in urlpatterns: if hasattr(p, "url_patterns"): try: patterns = p.url_patterns except ImportError: continue views.extend( extract_views_from_urlpatterns( patterns, base + str(p.pattern), (namespace or []) + (p.namespace and [p.namespace] or []), ) ) elif hasattr(p, "callback"): try: views.append((p.callback, base + str(p.pattern), namespace, p.name)) except ViewDoesNotExist: continue else: raise TypeError(_("%s does not appear to be a urlpattern object") % p) return views def simplify_regex(pattern): r""" Clean up urlpattern regexes into something more readable by humans. For example, turn "^(?P\w+)/athletes/(?P\w+)/$" into "//athletes//". """ pattern = remove_non_capturing_groups(pattern) pattern = replace_named_groups(pattern) pattern = replace_unnamed_groups(pattern) pattern = replace_metacharacters(pattern) pattern = unescape_literals(pattern) if not pattern.startswith("/"): pattern = "/" + pattern return pattern