Coverage for python/felis/browser/render.py: 100%
86 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 08:42 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 08:42 +0000
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, _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 }
97def _none_as_empty(value: object) -> object:
98 """Show `None` in a template as nothing, rather than "None"."""
99 return "" if value is None else value
102def _environment(site: SiteMap) -> Environment:
103 """Return the Jinja environment, with filters giving templates the site
104 map.
105 """
106 env = Environment(
107 loader=PackageLoader("felis.browser", "templates"),
108 # Every template is HTML, including page_macros.j2, whose macros
109 # are compiled with its own setting, not their callers'.
110 autoescape=True,
111 trim_blocks=True,
112 lstrip_blocks=True,
113 finalize=_none_as_empty,
114 )
115 env.filters.update(_TemplateFilters(site).all())
116 return env
119def _write_page(
120 env: Environment,
121 output_dir: Path,
122 template_name: str,
123 page_path: str,
124 **context: object,
125) -> None:
126 """Render a template and write it to ``page_path`` under ``output_dir``."""
127 target_path = output_dir / page_path
128 target_path.parent.mkdir(parents=True, exist_ok=True)
129 # The site root, relative to the page, such as "../.." for a table page.
130 site_root = _relative_href(page_path, ".")
131 html = env.get_template(template_name).render(page_path=page_path, site_root=site_root, **context)
132 target_path.write_text(html, encoding="utf-8")
135def _stylesheet_names(config: SiteConfig) -> dict[str, Path]:
136 """Return the configured stylesheets by their numbered names in the site's
137 assets.
138 """
139 return {f"custom-{number}-{path.name}": path for number, path in enumerate(config.stylesheets, start=1)}
142def _icon_name(config: SiteConfig) -> str | None:
143 return f"site-icon-{config.icon.name}" if config.icon else None
146def _favicon_name(config: SiteConfig) -> str | None:
147 return f"favicon{config.favicon.suffix}" if config.favicon else None
150def _page_globals(config: SiteConfig) -> dict[str, Any]:
151 """Return the template variables shared by every page."""
152 today = datetime.now(UTC)
153 return {
154 "site_title": config.title,
155 "site_description": config.description,
156 "custom_stylesheets": list(_stylesheet_names(config)),
157 "site_icon": _icon_name(config),
158 "site_favicon": _favicon_name(config),
159 "diagrams": config.diagrams,
160 "show_empty_columns": config.show_empty_columns,
161 "maintainer": config.maintainer,
162 "build_date": f"{_MONTHS[today.month - 1]} {today.day}, {today.year}",
163 }
166def _write_assets(assets_dir: Path, site: SiteMap, config: SiteConfig) -> None:
167 """Write the files shared by the pages into the site's ``assets``
168 directory.
169 """
170 assets_dir.mkdir(parents=True, exist_ok=True)
171 # The built-in files, from the package.
172 for asset_name in _ASSETS:
173 source = resources.files("felis.browser").joinpath(f"static/{asset_name}").read_text(encoding="utf-8")
174 (assets_dir / asset_name).write_text(source, encoding="utf-8")
175 # The files named in the site configuration, by their names in the site.
176 config_files = _stylesheet_names(config)
177 icon_name = _icon_name(config)
178 if config.icon and icon_name:
179 config_files[icon_name] = config.icon
180 favicon_name = _favicon_name(config)
181 if config.favicon and favicon_name:
182 config_files[favicon_name] = config.favicon
183 for asset_name, path in config_files.items():
184 shutil.copyfile(path, assets_dir / asset_name)
185 nav_json = json.dumps(_NavData(site).data(), separators=(",", ":"))
186 (assets_dir / _NAV_DATA).write_text(f"window.FELIS_NAV = {nav_json};\n", encoding="utf-8")
189def render_static_site(schemas: Iterable[Schema], output_dir: Path, config: SiteConfig | None = None) -> None:
190 """Render a static browser site for browsing schemas.
192 The generated pages include schema, table, and column navigation.
194 Parameters
195 ----------
196 schemas
197 Schemas to render.
198 output_dir
199 Directory where the rendered HTML files will be saved.
200 config
201 How the site looks; the defaults if not given. Its stylesheets are
202 copied into the site's ``assets`` directory as ``custom-<N>-<name>``.
203 """
204 config = config or SiteConfig()
205 site = SiteMap(schemas)
206 env = _environment(site)
207 env.globals.update(_page_globals(config))
209 _write_page(env, output_dir, "index.html.j2", "index.html", schemas=site.schemas)
210 for schema in site.schemas:
211 _write_page(env, output_dir, "schema.html.j2", site.path(schema), schema=schema)
212 for table in schema.tables:
213 tags = _column_tags(schema, table)
214 _write_page(
215 env, output_dir, "table.html.j2", site.path(table), schema=schema, table=table, tags=tags
216 )
217 _write_assets(output_dir / "assets", site, config)