Coverage for python/felis/browser/render.py: 100%

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-27 01:40 -0700

1"""Browser rendering utilities for generating static HTML pages from 

2schemas. 

3""" 

4 

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/>. 

25 

26from __future__ import annotations 

27 

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 

35 

36from jinja2 import Environment, PackageLoader, pass_context 

37from jinja2.runtime import Context 

38from lsst.utils.iteration import ensure_iterable 

39 

40from felis.datamodel import BaseObject, Column, Schema 

41 

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 

48 

49_ASSETS = ("style.css", "sidebar.js", "page.js", "search.js") 

50"""Static files copied into the site's ``assets`` directory.""" 

51 

52 

53_NAV_DATA = "nav-data.js" 

54"""Generated script defining ``window.FELIS_NAV``, the sidebar navigation data 

55shared by all pages. 

56""" 

57 

58 

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""" 

63 

64 

65class _TemplateFilters: 

66 """The template filters giving the pages access to the site map.""" 

67 

68 def __init__(self, site: SiteMap) -> None: 

69 self._site = site 

70 

71 @pass_context 

72 def href(self, context: Context, obj: BaseObject) -> str: 

73 return _relative_href(context["page_path"], self._site.path(obj)) 

74 

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)] 

78 

79 def relationship_diagram(self, schema: Schema) -> str | None: 

80 return _RelationshipDiagram(schema).text() 

81 

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 } 

95 

96 

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 

100 

101 

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 

117 

118 

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") 

133 

134 

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)} 

140 

141 

142def _icon_name(config: SiteConfig) -> str | None: 

143 return f"site-icon-{config.icon.name}" if config.icon else None 

144 

145 

146def _favicon_name(config: SiteConfig) -> str | None: 

147 return f"favicon{config.favicon.suffix}" if config.favicon else None 

148 

149 

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 } 

164 

165 

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") 

187 

188 

189def render_static_site(schemas: Iterable[Schema], output_dir: Path, config: SiteConfig | None = None) -> None: 

190 """Render a static browser site for browsing schemas. 

191 

192 The generated pages include schema, table, and column navigation. 

193 

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)) 

208 

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)