"""
Class for registering routes/url_patterns/urls/paths
"""
import re
import functools
from typing import Dict, Optional, Dict, Callable, List
from collections import defaultdict
from duck.exceptions.all import (
RouteError,
RouteNotFoundError,
)
from duck.utils.path import (
normalize_url_path,
is_good_url_path,
)
from duck.utils.lazy import Lazy
[docs]
class BaseRouteRegistry:
"""
A registry for storing and retrieving routes (URL patterns) with associated handlers.
This registry supports wildcard patterns (*), automatically replacing angle bracket placeholders like <name> with wildcards. It ensures uniqueness of routes both by name and by URL pattern.
"""
url_map = defaultdict(dict) # {normalized_url: {name: (handler, methods, pattern)}}
"""
Mapping of URLs to route details
"""
def __init__(self):
pass
[docs]
def regex_register(
self,
re_url: str,
handler: Callable,
name: Optional[str] = None,
methods: Optional[List[str]] = None,
**kw
):
"""
Registers a Regular expression route
Args:
re_url (str): Regular expression route (e.g /some/path/.*)
handler (Callable): The view or handler for the route.
name (Optional[str]): The name for the route. (optional)
methods (Optional[List[str]]): The supported methods for the route. Defaults to None to support all methods.
"""
re_url = "/" + re_url if not (re_url.startswith('/') or re_url.startswith('\\')) else re_url
methods = methods or []
assert callable(
handler), f"Handler argument should be a callable not '{handler}' "
if not name:
name = f"route_{len(self.url_map) + 1}" # Auto-generate names
pattern = re_url
# check for conflicts with existing patterns
for (
registered_url,
route_info,
) in self.url_map.items(): # iterate over registered URLs
for registered_name, (
_,
_,
existing_pattern,
) in route_info.items():
if name == registered_name:
raise RouteError(
f"URL '{re_url}' with name '{name}' already registered."
)
if existing_pattern.fullmatch(re_url) or re.compile(
pattern).fullmatch(registered_url):
raise RouteError(
f"Regex URL '{re_url}' conflicts with existing registered route '{registered_url}'."
)
type(self).url_map[re_url][name] = (
handler,
methods,
re.compile(pattern),
)
[docs]
def register(
self,
url_path: str,
handler: Callable,
name: Optional[str] = None,
methods: Optional[List[str]] = None,):
"""
Registers a Regular expression route
Args:
url_path (str): Regular expression route (e.g /some/path/.*)
handler (Callable): The view or handler for the route.
name (Optional[str]): The name for the route. (optional)
methods (Optional[List[str]]): The supported methods for the route. Defaults to None to support all methods.
Raises:
RouteError: If a route with the same name or conflicting URL pattern already exists or a Bad URL path
AssertionError: If handler argument is not a Callable
"""
methods = methods or []
assert callable(handler), f"Handler/View argument should be a callable not '{handler}' "
if "*" in url_path:
raise RouteError(f"Aterisks not supported, please use method regex_register instead. Route: {url_path}")
original_url = url_path
# Replace placeholders with wildcard aterisk (*)
url_path = re.sub(r"<[^>]+>", "*", url_path)
normalized_url = normalize_url_path(url_path.strip("/"))
normalized_original_url = normalize_url_path(original_url, ignore_chars=["<", ">"])
if not name:
name = f"route_{len(self.url_map) + 1}" # Auto-generate names
# convert wildcards to regex patterns
pattern = re.escape(normalized_url).replace(r"\*", ".*")
# check for conflicts with existing patterns
for (registered_url, route_info,) in self.url_map.items(): # iterate over registered URLs
for registered_name, (_, _, existing_pattern,) in route_info.items():
if name == registered_name:
raise RouteError(f"URL '{url_path}' with name '{name}' already registered.")
if existing_pattern.fullmatch(normalized_url) or re.compile(pattern).fullmatch(registered_url):
raise RouteError(f"URL '{url_path}' conflicts with existing registered route '{registered_url}'.")
type(self).url_map[normalized_original_url][name] = (
handler,
methods,
re.compile(pattern),
)
[docs]
@functools.lru_cache(maxsize=256)
def fetch_route_info_by_name(self, name: str) -> Dict:
"""
Fetches the handler, allowed methods, URL pattern for a route, etc by its name
Note: this does not generate any handler kwargs because a real URL is needed not a Name only
Args:
name (str): The name of the route to retrieve.
Returns:
Dict: A dictionary containing the name, handler function, handler keyword arguments, a list of allowed methods, and the URL pattern.
Raises:
RouteNotError: If no route with the given name is found.
"""
for registered_url, routes in self.url_map.items():
for registered_name, route_details in routes.items():
if registered_name == name:
handler, methods, pattern = route_details
return {
"name": registered_name,
"url": registered_url,
"handler": handler,
"methods": methods,
"pattern": pattern,
"handler_kwargs": {},
}
raise RouteNotFoundError(f'Route with name "{name}" not found')
[docs]
@functools.lru_cache(maxsize=256)
def fetch_route_info_by_url(self, url_path: str) -> Dict:
"""
Fetches the handler and allowed methods for a given URL path.
This generates handler kwargs rather than method fetch_route_info_by_name
Args:
url_path (str): The URL path to match.
Returns:
Dict: A dictionary containing the route details.
Raises:
RouteNotFoundError: If no matching route is found.
RouteError: If URL in bad format
"""
normalized_url = normalize_url_path(url_path)
if not is_good_url_path(normalized_url):
raise RouteError(
f"Bad URL path provided, should be in form '/path/subpath/' not '{url}' "
)
for registered_url, routes in self.url_map.items():
for name, (handler, methods, pattern) in routes.items():
if pattern.fullmatch(normalized_url):
return {
"name": name,
"url": registered_url,
"handler": handler,
"methods": methods,
"pattern": pattern,
"handler_kwargs": self.extract_kwargs_from_url(normalized_url, registered_url),
}
raise RouteNotFoundError(
f'Route "{url_path}" doesn\'t match any registered routes')
RouteRegistry = Lazy(BaseRouteRegistry)