import collections import importlib import os import re import sys from fnmatch import fnmatch from pathlib import Path from os.path import isfile, join from urllib.parse import parse_qs import flask from . import _validate from ._utils import AttributeDict from ._get_paths import get_relative_path from ._callback_context import context_value from ._get_app import get_app CONFIG = AttributeDict() PAGE_REGISTRY = collections.OrderedDict() def _infer_image(module): """ Return: - A page specific image: `assets/.` is used, e.g. `assets/weekly_analytics.png` - A generic app image at `assets/app.` - A logo at `assets/logo.` """ assets_folder = CONFIG.assets_folder valid_extensions = ["apng", "avif", "gif", "jpeg", "jpg", "png", "svg", "webp"] page_id = module.split(".")[-1] files_in_assets = [] if os.path.exists(assets_folder): files_in_assets = [ f for f in os.listdir(assets_folder) if isfile(join(assets_folder, f)) ] app_file = None logo_file = None for fn in files_in_assets: fn_without_extension, _, extension = fn.partition(".") if extension.lower() in valid_extensions: if ( fn_without_extension == page_id or fn_without_extension == page_id.replace("_", "-") ): return fn if fn_without_extension == "app": app_file = fn if fn_without_extension == "logo": logo_file = fn if app_file: return app_file return logo_file def _module_name_to_page_name(module_name): return module_name.split(".")[-1].replace("_", " ").capitalize() def _infer_path(module_name, template): if template is None: if CONFIG.pages_folder: pages_module = str(Path(CONFIG.pages_folder).name) path = ( module_name.split(pages_module)[-1] .replace("_", "-") .replace(".", "/") .lower() ) else: path = module_name.replace("_", "-").replace(".", "/").lower() else: # replace the variables in the template with "none" to create a default path if # no path is supplied path = re.sub("<.*?>", "none", template) path = "/" + path if not path.startswith("/") else path return path def _module_name_is_package(module_name): return ( module_name in sys.modules and Path(sys.modules[module_name].__file__).name == "__init__.py" ) def _path_to_module_name(path): return str(path).replace(".py", "").strip(os.sep).replace(os.sep, ".") def _infer_module_name(page_path): relative_path = page_path.split(CONFIG.pages_folder)[-1] module = _path_to_module_name(relative_path) proj_root = flask.helpers.get_root_path(CONFIG.name) if CONFIG.pages_folder.startswith(proj_root): parent_path = CONFIG.pages_folder[len(proj_root) :] else: parent_path = CONFIG.pages_folder parent_module = _path_to_module_name(parent_path) module_name = f"{parent_module}.{module}" if _module_name_is_package(CONFIG.name): # Only prefix with CONFIG.name when it's an imported package name module_name = f"{CONFIG.name}.{module_name}" return module_name def _parse_query_string(search): if search and len(search) > 0 and search[0] == "?": search = search[1:] else: return {} parsed_qs = {} for (k, v) in parse_qs(search).items(): v = v[0] if len(v) == 1 else v parsed_qs[k] = v return parsed_qs def _parse_path_variables(pathname, path_template): """ creates the dict of path variables passed to the layout e.g. path_template= "/asset/" if pathname provided by the browser is "/assets/a100" returns **{"asset_id": "a100"} """ # parse variable definitions e.g. from template # and create pattern to match wildcard_pattern = re.sub("<.*?>", "*", path_template) var_pattern = re.sub("<.*?>", "(.*)", path_template) # check that static sections of the pathname match the template if not fnmatch(pathname, wildcard_pattern): return None # parse variable names e.g. var_name from template var_names = re.findall("<(.*?)>", path_template) # parse variables from path variables = re.findall(var_pattern, pathname) variables = variables[0] if isinstance(variables[0], tuple) else variables return dict(zip(var_names, variables)) def _create_redirect_function(redirect_to): def redirect(): return flask.redirect(redirect_to, code=301) return redirect def _set_redirect(redirect_from, path): app = get_app() if redirect_from and len(redirect_from): for redirect in redirect_from: fullname = app.get_relative_path(redirect) app.server.add_url_rule( fullname, fullname, _create_redirect_function(app.get_relative_path(path)), ) def register_page( module, path=None, path_template=None, name=None, order=None, title=None, description=None, image=None, image_url=None, redirect_from=None, layout=None, **kwargs, ): """ Assigns the variables to `dash.page_registry` as an `OrderedDict` (ordered by `order`). `dash.page_registry` is used by `pages_plugin` to set up the layouts as a multi-page Dash app. This includes the URL routing callbacks (using `dcc.Location`) and the HTML templates to include title, meta description, and the meta description image. `dash.page_registry` can also be used by Dash developers to create the page navigation links or by template authors. - `module`: The module path where this page's `layout` is defined. Often `__name__`. - `path`: URL Path, e.g. `/` or `/home-page`. If not supplied, will be inferred from the `path_template` or `module`, e.g. based on path_template: `/asset/. The layout function then receives the as a keyword argument. e.g. path_template= "/asset/" then if pathname in browser is "/assets/a100" then layout will receive **{"asset_id":"a100"} - `name`: The name of the link. If not supplied, will be inferred from `module`, e.g. `pages.weekly_analytics` to `Weekly analytics` - `order`: The order of the pages in `page_registry`. If not supplied, then the filename is used and the page with path `/` has order `0` - `title`: (string or function) The name of the page . That is, what appears in the browser title. If not supplied, will use the supplied `name` or will be inferred by module, e.g. `pages.weekly_analytics` to `Weekly analytics` - `description`: (string or function) The <meta type="description"></meta>. If not supplied, then nothing is supplied. - `image`: The meta description image used by social media platforms. If not supplied, then it looks for the following images in `assets/`: - A page specific image: `assets/<module>.<extension>` is used, e.g. `assets/weekly_analytics.png` - A generic app image at `assets/app.<extension>` - A logo at `assets/logo.<extension>` When inferring the image file, it will look for the following extensions: APNG, AVIF, GIF, JPEG, JPG, PNG, SVG, WebP. - `image_url`: Overrides the image property and sets the `<image>` meta tag to the provided image URL. - `redirect_from`: A list of paths that should redirect to this page. For example: `redirect_from=['/v2', '/v3']` - `layout`: The layout function or component for this page. If not supplied, then looks for `layout` from within the supplied `module`. - `**kwargs`: Arbitrary keyword arguments that can be stored *** `page_registry` stores the original property that was passed in under `supplied_<property>` and the coerced property under `<property>`. For example, if this was called: ``` register_page( 'pages.historical_outlook', name='Our historical view', custom_key='custom value' ) ``` Then this will appear in `page_registry`: ``` OrderedDict([ ( 'pages.historical_outlook', dict( module='pages.historical_outlook', supplied_path=None, path='/historical-outlook', supplied_name='Our historical view', name='Our historical view', supplied_title=None, title='Our historical view' supplied_layout=None, layout=<function pages.historical_outlook.layout>, custom_key='custom value' ) ), ]) ``` """ if context_value.get().get("ignore_register_page"): return _validate.validate_use_pages(CONFIG) page = dict( module=_validate.validate_module_name(module), supplied_path=path, path_template=path_template, path=path if path is not None else _infer_path(module, path_template), supplied_name=name, name=name if name is not None else _module_name_to_page_name(module), ) page.update( supplied_title=title, title=(title if title is not None else page["name"]), ) page.update( description=description if description else "", order=order, supplied_order=order, supplied_layout=layout, **kwargs, ) page.update( supplied_image=image, image=(image if image is not None else _infer_image(module)), image_url=image_url, ) page.update(redirect_from=_set_redirect(redirect_from, page["path"])) PAGE_REGISTRY[module] = page if page["path_template"]: _validate.validate_template(page["path_template"]) if layout is not None: # Override the layout found in the file set during `plug` PAGE_REGISTRY[module]["layout"] = layout # set home page order order_supplied = any( p["supplied_order"] is not None for p in PAGE_REGISTRY.values() ) for p in PAGE_REGISTRY.values(): p["order"] = ( 0 if p["path"] == "/" and not order_supplied else p["supplied_order"] ) p["relative_path"] = get_relative_path(p["path"]) # Sort numeric orders first, then string orders, then no order, # finally by module name for matching orders for page in sorted( PAGE_REGISTRY.values(), key=lambda i: ( i["order"] is None, # False (order given) sorts before True i["order"] if isinstance(i["order"], (int, float)) else float("inf"), str(i["order"]), i["module"], ), ): PAGE_REGISTRY.move_to_end(page["module"]) def _path_to_page(path_id): path_variables = None for page in PAGE_REGISTRY.values(): if page["path_template"]: template_id = page["path_template"].strip("/") path_variables = _parse_path_variables(path_id, template_id) if path_variables: return page, path_variables if path_id == page["path"].strip("/"): return page, path_variables return {}, None def _page_meta_tags(app): start_page, path_variables = _path_to_page(flask.request.path.strip("/")) # use the supplied image_url or create url based on image in the assets folder image = start_page.get("image", "") if image: image = app.get_asset_url(image) assets_image_url = ( "".join([flask.request.url_root, image.lstrip("/")]) if image else None ) supplied_image_url = start_page.get("image_url") image_url = supplied_image_url if supplied_image_url else assets_image_url title = start_page.get("title", app.title) if callable(title): title = title(**path_variables) if path_variables else title() description = start_page.get("description", "") if callable(description): description = description(**path_variables) if path_variables else description() return [ {"name": "description", "content": description}, {"property": "twitter:card", "content": "summary_large_image"}, {"property": "twitter:url", "content": flask.request.url}, {"property": "twitter:title", "content": title}, {"property": "twitter:description", "content": description}, {"property": "twitter:image", "content": image_url or ""}, {"property": "og:title", "content": title}, {"property": "og:type", "content": "website"}, {"property": "og:description", "content": description}, {"property": "og:image", "content": image_url or ""}, ] def _import_layouts_from_pages(pages_folder): for root, dirs, files in os.walk(pages_folder): dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")] for file in files: if file.startswith("_") or file.startswith(".") or not file.endswith(".py"): continue page_path = os.path.join(root, file) with open(page_path, encoding="utf-8") as f: content = f.read() if "register_page" not in content: continue module_name = _infer_module_name(page_path) spec = importlib.util.spec_from_file_location(module_name, page_path) page_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(page_module) sys.modules[module_name] = page_module if ( module_name in PAGE_REGISTRY and not PAGE_REGISTRY[module_name]["supplied_layout"] ): _validate.validate_pages_layout(module_name, page_module) PAGE_REGISTRY[module_name]["layout"] = getattr(page_module, "layout")