Coverage for python/felis/browser/render.py: 100%
86 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 01:39 -0700
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 01:39 -0700
1"""Browser rendering utilities for generating static HTML pages from
2schemas.
3"""
5# This file is part of felis.
6#
7# Developed for the LSST Data Management System.
8# This product includes software developed by the LSST Project
9# (https://www.lsst.org).
10# See the COPYRIGHT file at the top-level directory of this distribution
11# for details of code ownership.
12#
13# This program is free software: you can redistribute it and/or modify
14# it under the terms of the GNU General Public License as published by
15# the Free Software Foundation, either version 3 of the License, or
16# (at your option) any later version.
17#
18# This program is distributed in the hope that it will be useful,
19# but WITHOUT ANY WARRANTY; without even the implied warranty of
20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21# GNU General Public License for more details.
22#
23# You should have received a copy of the GNU General Public License
24# along with this program. If not, see <https://www.gnu.org/licenses/>.
26from __future__ import annotations
28import json
29import shutil
30from collections.abc import Iterable
31from datetime import UTC, datetime
32from importlib import resources
33from pathlib import Path
34from typing import Any
36from jinja2 import Environment, PackageLoader, pass_context
37from jinja2.runtime import Context
38from lsst.utils.iteration import ensure_iterable
40from felis.datamodel import BaseObject, Column, Schema
42from .columns import _column_field, _column_tags, _table_column_fields
43from .config import SiteConfig
44from .diagram import _RelationshipDiagram
45from .navdata import _NavData
46from .schemas import _resolve_column, _schema_version, _sorted_by_name, _version_order
47from .sitemap import SiteMap, _relative_href
49_ASSETS = ("style.css", "sidebar.js", "page.js", "search.js")
50"""Static files copied into the site's ``assets`` directory."""
53_NAV_DATA = "nav-data.js"
54"""Generated script defining ``window.FELIS_NAV``, the sidebar navigation data
55shared by all pages.
56"""
59_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
60"""Month names for the build date in the footer, in English whatever the
61locale, which ``%b`` would follow.
62"""
65class _TemplateFilters:
66 """The template filters giving the pages access to the site map."""
68 def __init__(self, site: SiteMap) -> None:
69 self._site = site
71 @pass_context
72 def href(self, context: Context, obj: BaseObject) -> str:
73 return _relative_href(context["page_path"], self._site.path(obj))
75 @pass_context
76 def columns(self, context: Context, refs: str | list[str]) -> list[Column]:
77 return [_resolve_column(context["schema"], ref) for ref in ensure_iterable(refs)]
79 def relationship_diagram(self, schema: Schema) -> str | None:
80 return _RelationshipDiagram(schema).text()
82 def all(self) -> dict[str, Any]:
83 return {
84 "href": self.href,
85 "path": self._site.path,
86 "anchor": self._site.anchor,
87 "table_of": self._site.table_of,
88 "columns": self.columns,
89 "relationship_diagram": self.relationship_diagram,
90 "field": _column_field,
91 "column_fields": _table_column_fields,
92 "version": _schema_version,
93 "version_order": _version_order,
94 "sorted_by_name": _sorted_by_name,
95 }
98def _none_as_empty(value: object) -> object:
99 """Show `None` in a template as nothing, rather than "None"."""
100 return "" if value is None else value
103def _environment(site: SiteMap) -> Environment:
104 """Return the Jinja environment, with filters giving templates the site
105 map.
106 """
107 env = Environment(
108 loader=PackageLoader("felis.browser", "templates"),
109 # Every template is HTML, including page_macros.j2, whose macros
110 # are compiled with its own setting, not their callers'.
111 autoescape=True,
112 trim_blocks=True,
113 lstrip_blocks=True,
114 finalize=_none_as_empty,
115 )
116 env.filters.update(_TemplateFilters(site).all())
117 return env
120def _write_page(
121 env: Environment,
122 output_dir: Path,
123 template_name: str,
124 page_path: str,
125 **context: object,
126) -> None:
127 """Render a template and write it to ``page_path`` under ``output_dir``."""
128 target_path = output_dir / page_path
129 target_path.parent.mkdir(parents=True, exist_ok=True)
130 # The site root, relative to the page, such as "../.." for a table page.
131 site_root = _relative_href(page_path, ".")
132 html = env.get_template(template_name).render(page_path=page_path, site_root=site_root, **context)
133 target_path.write_text(html, encoding="utf-8")
136def _stylesheet_names(config: SiteConfig) -> dict[str, Path]:
137 """Return the configured stylesheets by their numbered names in the site's
138 assets.
139 """
140 return {f"custom-{number}-{path.name}": path for number, path in enumerate(config.stylesheets, start=1)}
143def _icon_name(config: SiteConfig) -> str | None:
144 return f"site-icon-{config.icon.name}" if config.icon else None
147def _favicon_name(config: SiteConfig) -> str | None:
148 return f"favicon{config.favicon.suffix}" if config.favicon else None
151def _page_globals(config: SiteConfig) -> dict[str, Any]:
152 """Return the template variables shared by every page."""
153 today = datetime.now(UTC)
154 return {
155 "site_title": config.title,
156 "site_description": config.description,
157 "custom_stylesheets": list(_stylesheet_names(config)),
158 "site_icon": _icon_name(config),
159 "site_favicon": _favicon_name(config),
160 "diagrams": config.diagrams,
161 "show_empty_columns": config.show_empty_columns,
162 "maintainer": config.maintainer,
163 "build_date": f"{_MONTHS[today.month - 1]} {today.day}, {today.year}",
164 }
167def _write_assets(assets_dir: Path, site: SiteMap, config: SiteConfig) -> None:
168 """Write the files shared by the pages into the site's ``assets``
169 directory.
170 """
171 assets_dir.mkdir(parents=True, exist_ok=True)
172 # The built-in files, from the package.
173 for asset_name in _ASSETS:
174 source = resources.files("felis.browser").joinpath(f"static/{asset_name}").read_text(encoding="utf-8")
175 (assets_dir / asset_name).write_text(source, encoding="utf-8")
176 # The files named in the site configuration, by their names in the site.
177 config_files = _stylesheet_names(config)
178 icon_name = _icon_name(config)
179 if config.icon and icon_name:
180 config_files[icon_name] = config.icon
181 favicon_name = _favicon_name(config)
182 if config.favicon and favicon_name:
183 config_files[favicon_name] = config.favicon
184 for asset_name, path in config_files.items():
185 shutil.copyfile(path, assets_dir / asset_name)
186 nav_json = json.dumps(_NavData(site).data(), separators=(",", ":"))
187 (assets_dir / _NAV_DATA).write_text(f"window.FELIS_NAV = {nav_json};\n", encoding="utf-8")
190def render_static_site(schemas: Iterable[Schema], output_dir: Path, config: SiteConfig | None = None) -> None:
191 """Render a static browser site for browsing schemas.
193 The generated pages include schema, table, and column navigation.
195 Parameters
196 ----------
197 schemas
198 Schemas to render.
199 output_dir
200 Directory where the rendered HTML files will be saved.
201 config
202 How the site looks; the defaults if not given. Its stylesheets are
203 copied into the site's ``assets`` directory as ``custom-<N>-<name>``.
204 """
205 config = config or SiteConfig()
206 site = SiteMap(schemas)
207 env = _environment(site)
208 env.globals.update(_page_globals(config))
210 _write_page(env, output_dir, "index.html.j2", "index.html", schemas=site.schemas)
211 for schema in site.schemas:
212 _write_page(env, output_dir, "schema.html.j2", site.path(schema), schema=schema)
213 for table in schema.tables:
214 tags = _column_tags(schema, table)
215 _write_page(
216 env, output_dir, "table.html.j2", site.path(table), schema=schema, table=table, tags=tags
217 )
218 _write_assets(output_dir / "assets", site, config)